stunk 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -127
- package/dist/core/asyncChunk.js +46 -1
- package/dist/core/types.js +1 -0
- package/dist/middleware/index.js +3 -0
- package/dist/middleware/persistence.js +25 -0
- package/dist/utils.js +30 -0
- package/package.json +8 -2
- package/src/core/asyncChunk.ts +83 -0
- package/src/core/types.ts +17 -0
- package/src/middleware/history.ts +18 -0
- package/src/middleware/index.ts +4 -0
- package/src/middleware/persistence.ts +45 -0
- package/src/utils.ts +46 -0
- package/tests/async-chunk.test.ts +215 -0
- package/tests/persist.test.ts +57 -0
package/README.md
CHANGED
|
@@ -1,92 +1,60 @@
|
|
|
1
1
|
# Stunk
|
|
2
2
|
|
|
3
|
-
A lightweight, framework-agnostic state management library using atomic state principles. Stunk breaks down state into manageable "chunks" for easy updates and subscriptions.
|
|
4
|
-
|
|
5
|
-
## Pronunciation and Meaning
|
|
6
|
-
|
|
7
3
|
- **Pronunciation**: _Stunk_ (A playful blend of "state" and "chunk")
|
|
8
|
-
- **Meaning**: "Stunk" represents the combination of state management with chunk-based atomic units. The term captures the essence of atomic state management while using "chunk" to refer to these discrete units of state.
|
|
9
|
-
|
|
10
|
-
## What is Stunk?
|
|
11
4
|
|
|
12
|
-
|
|
5
|
+
A lightweight, reactive state management library for TypeScript/JavaScript applications. Stunk combines atomic state management with powerful features like middleware, time travel, and async state handling.
|
|
13
6
|
|
|
14
7
|
**Stunk** is like dividing your jar into many smaller containers, each holding a single piece of state. These smaller containers are called **chunks**. Each **chunk** can be updated and accessed easily, and any part of your app can subscribe to changes in a chunk so it gets updated automatically.
|
|
15
8
|
|
|
16
9
|
## Features
|
|
17
10
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
- 🎯
|
|
22
|
-
-
|
|
23
|
-
- 🔄
|
|
24
|
-
-
|
|
25
|
-
-
|
|
11
|
+
- 🚀 **Lightweight and Fast**: No dependencies, minimal overhead
|
|
12
|
+
- 🔄 **Reactive**: Automatic updates when state changes
|
|
13
|
+
- 📦 **Batch Updates**: Group multiple state updates together
|
|
14
|
+
- 🎯 **Atomic State Management**: Break down state into manageable chunks
|
|
15
|
+
- 🎭 **State Selection**: Select and derive specific parts of state
|
|
16
|
+
- 🔄 **Async Support**: Handle async state with built-in loading and error states
|
|
17
|
+
- 🔌 **Middleware Support**: Extend functionality with custom middleware
|
|
18
|
+
- ⏱️ **Time Travel**: Undo/redo state changes
|
|
19
|
+
- 🔍 **Type-Safe**: Written in TypeScript with full type inference
|
|
26
20
|
|
|
27
21
|
## Installation
|
|
28
22
|
|
|
29
23
|
```bash
|
|
30
24
|
npm install stunk
|
|
25
|
+
# or
|
|
26
|
+
yarn add stunk
|
|
31
27
|
```
|
|
32
28
|
|
|
33
|
-
##
|
|
34
|
-
|
|
35
|
-
```typescript
|
|
36
|
-
import { chunk, select, batch } from "stunk";
|
|
37
|
-
|
|
38
|
-
// Create a state chunk
|
|
39
|
-
const counter = chunk(0);
|
|
40
|
-
const userChunk = chunk({ name: "Olamide", age: 26 });
|
|
41
|
-
|
|
42
|
-
// Select specific state - Selector
|
|
43
|
-
const nameSelector = select(userChunk, (user) => user.name);
|
|
44
|
-
|
|
45
|
-
// Subscribe to changes
|
|
46
|
-
nameSelector.subscribe((name) => console.log("Name:", name));
|
|
47
|
-
counter.subscribe((count) => console.log("Counter", counter));
|
|
48
|
-
|
|
49
|
-
// Batch updates
|
|
50
|
-
batch(() => {
|
|
51
|
-
userChunk.set({ name: "Olalekan", age: 27 }); // Doesn't log yet
|
|
52
|
-
counter.set(5); // Doesn't log yet
|
|
53
|
-
}); // All logs happen here at once
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
## Core Concepts
|
|
57
|
-
|
|
58
|
-
### Chunks
|
|
29
|
+
## Basic Usage
|
|
59
30
|
|
|
60
|
-
|
|
31
|
+
A **chunk** is a small container of state. It holds a value, and you can do some stuffs with it:
|
|
61
32
|
|
|
62
33
|
```typescript
|
|
63
|
-
|
|
64
|
-
counter.subscribe((value) => console.log(value));
|
|
65
|
-
counter.set(5);
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
## Unsubscribing
|
|
69
|
-
|
|
70
|
-
You can **unsubscribe** from a **chunk**, which means you stop getting notifications when the **value changes**. You can do this by calling the function that's returned when you **subscribe..**
|
|
34
|
+
import { chunk } from "stunk";
|
|
71
35
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
```ts
|
|
75
|
-
const count = chunk(0);
|
|
76
|
-
const callback = (newValue: number) => console.log("Updated value:", newValue);
|
|
36
|
+
// Create a simple counter
|
|
37
|
+
const counterChunk = chunk(0);
|
|
77
38
|
|
|
78
|
-
|
|
39
|
+
// Subscribe to changes
|
|
40
|
+
counterChunk.subscribe((value) => {
|
|
41
|
+
console.log("Counter changed:", value);
|
|
42
|
+
});
|
|
79
43
|
|
|
80
|
-
|
|
44
|
+
// Update the value
|
|
45
|
+
counterChunk.set(1);
|
|
81
46
|
|
|
82
|
-
|
|
47
|
+
// Get current value
|
|
48
|
+
const value = counterChunk.get(); // 1
|
|
83
49
|
|
|
84
|
-
|
|
50
|
+
// Reset to initial value
|
|
51
|
+
counterChunk.reset();
|
|
85
52
|
```
|
|
86
53
|
|
|
87
|
-
|
|
54
|
+
## Deriving New Chunks
|
|
88
55
|
|
|
89
|
-
With Stunk
|
|
56
|
+
With **Stunk**, you can create **derived chunks**. This means you can create a new **chunk** based on the value of another **chunk**.
|
|
57
|
+
When the original **chunk** changes, the **derived chunk** will automatically update.
|
|
90
58
|
|
|
91
59
|
```typescript
|
|
92
60
|
const count = chunk(5);
|
|
@@ -103,34 +71,51 @@ count.set(10);
|
|
|
103
71
|
// "Double count: 20"
|
|
104
72
|
```
|
|
105
73
|
|
|
106
|
-
|
|
74
|
+
## Batch Updates
|
|
107
75
|
|
|
108
|
-
|
|
76
|
+
Batch Update group multiple **state changes** together and notify **subscribers** only once at the end of the **batch**. This is particularly useful for **optimizing performance** when you need to **update multiple** chunks at the same time.
|
|
109
77
|
|
|
110
78
|
```typescript
|
|
79
|
+
import { chunk, batch } from "stunk";
|
|
80
|
+
|
|
81
|
+
const nameChunk = chunk("Olamide");
|
|
82
|
+
const ageChunk = chunk(30);
|
|
83
|
+
|
|
111
84
|
batch(() => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}); //
|
|
85
|
+
nameChunk.set("AbdulAzeez");
|
|
86
|
+
ageChunk.set(31);
|
|
87
|
+
}); // Only one notification will be sent to subscribers
|
|
115
88
|
|
|
116
89
|
// Nested batches are also supported
|
|
117
90
|
batch(() => {
|
|
118
|
-
|
|
91
|
+
firstName.set("Olanrewaju");
|
|
119
92
|
batch(() => {
|
|
120
|
-
|
|
93
|
+
age.set(29);
|
|
121
94
|
});
|
|
122
|
-
});
|
|
95
|
+
}); // Only one notification will be sent to subscribers
|
|
123
96
|
```
|
|
124
97
|
|
|
125
|
-
|
|
98
|
+
## State Selection
|
|
126
99
|
|
|
127
100
|
Efficiently access and react to specific state parts:
|
|
128
101
|
|
|
129
102
|
```typescript
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
103
|
+
import { chunk, select } from "stunk";
|
|
104
|
+
|
|
105
|
+
const userChunk = chunk({
|
|
106
|
+
name: "Olamide",
|
|
107
|
+
age: 30,
|
|
108
|
+
email: "olamide@example.com",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Select specific properties -readonly
|
|
112
|
+
const nameChunk = select(userChunk, (state) => state.name);
|
|
113
|
+
const ageChunk = select(userChunk, (state) => state.age);
|
|
114
|
+
|
|
115
|
+
nameChunk.subscribe((name) => console.log("Name changed:", name));
|
|
116
|
+
// will only re-render if the selected part change.
|
|
117
|
+
|
|
118
|
+
nameChunk.set("Olamide"); // ❌ this will throw an error, because it is a readonly.
|
|
134
119
|
```
|
|
135
120
|
|
|
136
121
|
## Middleware
|
|
@@ -138,46 +123,135 @@ const scoreSelector = select(userChunk, (u) => u.score);
|
|
|
138
123
|
Middleware allows you to customize how values are set in a **chunk**. For example, you can add **logging**, **validation**, or any custom behavior when a chunk's value changes.
|
|
139
124
|
|
|
140
125
|
```typescript
|
|
141
|
-
|
|
126
|
+
import { chunk } from "stunk";
|
|
127
|
+
import { logger, nonNegativeValidator } from "stunk/middleware";
|
|
128
|
+
|
|
129
|
+
// You can also create yours and pass it chunk as the second param
|
|
142
130
|
|
|
143
131
|
// Use middleware for logging and validation
|
|
144
132
|
const age = chunk(25, [logger, nonNegativeValidator]);
|
|
145
133
|
|
|
146
134
|
age.set(30); // Logs: "Setting value: 30"
|
|
147
|
-
age.set(-5); // Throws an error: "Value must be non-negative!"
|
|
135
|
+
age.set(-5); // ❌ Throws an error: "Value must be non-negative!"
|
|
148
136
|
```
|
|
149
137
|
|
|
150
|
-
|
|
138
|
+
## Time Travel (Middleware)
|
|
151
139
|
|
|
152
140
|
```typescript
|
|
153
|
-
|
|
141
|
+
import { chunk } from "stunk";
|
|
142
|
+
import { withHistory } from "stunk/midddleware";
|
|
143
|
+
|
|
144
|
+
const counterChunk = withHistory(chunk(0));
|
|
145
|
+
|
|
146
|
+
counterChunk.set(1);
|
|
147
|
+
counterChunk.set(2);
|
|
154
148
|
|
|
155
|
-
|
|
156
|
-
|
|
149
|
+
counterChunk.undo(); // Goes back to 1
|
|
150
|
+
counterChunk.undo(); // Goes back to 0
|
|
157
151
|
|
|
158
|
-
|
|
152
|
+
counterChunk.redo(); // Goes forward to 1
|
|
159
153
|
|
|
160
|
-
|
|
161
|
-
|
|
154
|
+
counterChunk.canUndo(); // Returns `true` if there is a previous state to revert to..
|
|
155
|
+
counterChunk.canRedo(); // Returns `true` if there is a next state to move to.
|
|
162
156
|
|
|
163
|
-
|
|
164
|
-
|
|
157
|
+
counterChunk.getHistory(); // Returns an array of all the values in the history.
|
|
158
|
+
|
|
159
|
+
counterChunk.clearHistory(); // Clears the history, keeping only the current value.
|
|
165
160
|
```
|
|
166
161
|
|
|
167
162
|
**Example: Limiting History Size (Optional)**
|
|
168
163
|
You can specify a max history size to prevent excessive memory usage.
|
|
169
164
|
|
|
170
165
|
```ts
|
|
171
|
-
const counter = withHistory(chunk(0), { maxHistory: 5 });
|
|
166
|
+
const counter = withHistory(chunk(0), { maxHistory: 5 });
|
|
167
|
+
// Only keeps the last 5 changes -- default is 100.
|
|
172
168
|
```
|
|
173
169
|
|
|
174
170
|
This prevents the history from growing indefinitely and ensures efficient memory usage.
|
|
175
171
|
|
|
172
|
+
## State Persistence
|
|
173
|
+
|
|
174
|
+
Stunk provides a persistence middleware to automatically save state changes to storage (localStorage, sessionStorage, etc).
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { chunk } from "stunk";
|
|
178
|
+
import { withPersistence } from "stunk/middleware";
|
|
179
|
+
|
|
180
|
+
const counterChunk = withPersistence(chunk({ count: 0 }), {
|
|
181
|
+
key: "counter-state",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// State automatically persists to localStorage
|
|
185
|
+
counterChunk.set({ count: 1 });
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Async State
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import { asyncChunk } from "stunk";
|
|
192
|
+
|
|
193
|
+
type User = {
|
|
194
|
+
id: number;
|
|
195
|
+
name: string;
|
|
196
|
+
email: string;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const user = asyncChunk<User>(async () => {
|
|
200
|
+
const response = await fetch("/api/user");
|
|
201
|
+
return response.json(); // TypeScript expects this to return User;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Now userChunk is typed as AsyncChunk<User>, which means:
|
|
205
|
+
user.subscribe((state) => {
|
|
206
|
+
if (state.data) {
|
|
207
|
+
// state.data is typed as User | null
|
|
208
|
+
console.log(state.data.name); // TypeScript knows 'name' exists
|
|
209
|
+
console.log(state.data.age); // ❌ TypeScript Error: Property 'age' does not exist
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
user.subscribe(({ loading, error, data }) => {
|
|
214
|
+
if (loading) console.log("Loading...");
|
|
215
|
+
if (error) console.log("Error:", error);
|
|
216
|
+
if (data) console.log("User:", data);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Reload data
|
|
220
|
+
await user.reload();
|
|
221
|
+
|
|
222
|
+
// Optimistic update
|
|
223
|
+
user.mutate((currentData) => ({
|
|
224
|
+
...currentData,
|
|
225
|
+
name: "Fola",
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
// The mutate function also enforces the User type
|
|
229
|
+
user.mutate(currentUser => ({
|
|
230
|
+
id: currentUser?.id ?? 0,
|
|
231
|
+
name: "Olamide",
|
|
232
|
+
email: "olamide@gmail.com"
|
|
233
|
+
age: 70 // ❌ TypeScript Error: Object literal may only specify known properties
|
|
234
|
+
}));
|
|
235
|
+
```
|
|
236
|
+
|
|
176
237
|
## API Reference
|
|
177
238
|
|
|
178
|
-
###
|
|
239
|
+
### Core
|
|
240
|
+
|
|
241
|
+
- `chunk<T>(initialValue: T): Chunk<T>`
|
|
242
|
+
- `batch(fn: () => void): void`
|
|
243
|
+
- `select<T, S>(sourceChunk: Chunk<T>, selector: (state: T) => S): Chunk<S>`
|
|
244
|
+
<!-- - `asyncChunk<T>(fetcher: () => Promise<T>, options?): AsyncChunk<T>` -->
|
|
245
|
+
|
|
246
|
+
### History
|
|
247
|
+
|
|
248
|
+
- `withHistory<T>(chunk: Chunk<T>, options: { maxHistory?: number }): ChunkWithHistory<T>`
|
|
249
|
+
|
|
250
|
+
### Persistance
|
|
251
|
+
|
|
252
|
+
- `withPersistence<T>(baseChunk: Chunk<T>,options: PersistOptions<T>): Chunk<T>`
|
|
179
253
|
|
|
180
|
-
|
|
254
|
+
### Types
|
|
181
255
|
|
|
182
256
|
```typescript
|
|
183
257
|
interface Chunk<T> {
|
|
@@ -185,62 +259,50 @@ interface Chunk<T> {
|
|
|
185
259
|
set(value: T): void;
|
|
186
260
|
subscribe(callback: (value: T) => void): () => void;
|
|
187
261
|
derive<D>(fn: (value: T) => D): Chunk<D>;
|
|
262
|
+
reset(): void;
|
|
188
263
|
destroy(): void;
|
|
189
264
|
}
|
|
190
265
|
```
|
|
191
266
|
|
|
192
|
-
### `select<T, S>(sourceChunk: Chunk<T>, selector: (value: T) => S)`
|
|
193
|
-
|
|
194
|
-
Creates an optimized selector.
|
|
195
|
-
|
|
196
267
|
```typescript
|
|
197
|
-
|
|
198
|
-
|
|
268
|
+
interface AsyncState<T> {
|
|
269
|
+
loading: boolean;
|
|
270
|
+
error: Error | null;
|
|
271
|
+
data: T | null;
|
|
272
|
+
}
|
|
199
273
|
```
|
|
200
274
|
|
|
201
|
-
### `batch(callback: () => void)`
|
|
202
|
-
|
|
203
|
-
Batches multiple updates.
|
|
204
|
-
|
|
205
275
|
```typescript
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
batch(() => {
|
|
211
|
-
// Multiple updates here
|
|
212
|
-
batch(() => {
|
|
213
|
-
// Nested upddates here
|
|
214
|
-
});
|
|
215
|
-
});
|
|
276
|
+
interface AsyncChunk<T> extends Chunk<AsyncState<T>> {
|
|
277
|
+
reload(): Promise<void>;
|
|
278
|
+
mutate(mutator: (currentData: T | null) => T): void;
|
|
279
|
+
}
|
|
216
280
|
```
|
|
217
281
|
|
|
218
|
-
### `withHistory<T>(chunk: Chunk<T>, options?: { maxHistory?: number })`
|
|
219
|
-
|
|
220
|
-
Adds undo/redo capabilities.
|
|
221
|
-
|
|
222
282
|
```typescript
|
|
223
283
|
interface ChunkWithHistory<T> extends Chunk<T> {
|
|
224
|
-
undo()
|
|
225
|
-
redo()
|
|
226
|
-
canUndo()
|
|
227
|
-
canRedo()
|
|
228
|
-
getHistory()
|
|
229
|
-
clearHistory()
|
|
284
|
+
undo: () => void;
|
|
285
|
+
redo: () => void;
|
|
286
|
+
canUndo: () => boolean;
|
|
287
|
+
canRedo: () => boolean;
|
|
288
|
+
getHistory: () => T[];
|
|
289
|
+
clearHistory: () => void;
|
|
230
290
|
}
|
|
231
291
|
```
|
|
232
292
|
|
|
233
|
-
### `Middleware<T>`
|
|
234
|
-
|
|
235
|
-
Custom state processing:
|
|
236
|
-
|
|
237
293
|
```typescript
|
|
238
|
-
|
|
294
|
+
interface PersistOptions<T> {
|
|
295
|
+
key: string; // Storage key
|
|
296
|
+
storage?: Storage; // Storage mechanism (default: localStorage)
|
|
297
|
+
serialize?: (value: T) => string; // Custom serializer
|
|
298
|
+
deserialize?: (value: string) => T; // Custom deserializer
|
|
299
|
+
}
|
|
239
300
|
```
|
|
240
301
|
|
|
241
|
-
|
|
242
|
-
|
|
302
|
+
## Contributing
|
|
303
|
+
|
|
304
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
243
305
|
|
|
244
306
|
## License
|
|
245
307
|
|
|
246
|
-
MIT
|
|
308
|
+
This is licence under MIT
|
package/dist/core/asyncChunk.js
CHANGED
|
@@ -1 +1,46 @@
|
|
|
1
|
-
|
|
1
|
+
import { chunk } from "./core";
|
|
2
|
+
export function asyncChunk(fetcher, options = {}) {
|
|
3
|
+
const { initialData = null, onError, retryCount = 0, retryDelay = 1000, } = options;
|
|
4
|
+
const initialState = {
|
|
5
|
+
loading: true,
|
|
6
|
+
error: null,
|
|
7
|
+
data: initialData,
|
|
8
|
+
};
|
|
9
|
+
const baseChunk = chunk(initialState);
|
|
10
|
+
const fetchData = async (retries = retryCount) => {
|
|
11
|
+
baseChunk.set({ ...baseChunk.get(), loading: true, error: null });
|
|
12
|
+
try {
|
|
13
|
+
const data = await fetcher();
|
|
14
|
+
baseChunk.set({ loading: false, error: null, data });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (retries > 0) {
|
|
18
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
19
|
+
return fetchData(retries - 1);
|
|
20
|
+
}
|
|
21
|
+
const errorObj = error instanceof Error ? error : new Error(String(error));
|
|
22
|
+
baseChunk.set({ loading: false, error: errorObj, data: baseChunk.get().data });
|
|
23
|
+
if (onError) {
|
|
24
|
+
onError(errorObj);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
// Initial fetch
|
|
29
|
+
fetchData();
|
|
30
|
+
const asyncChunkInstance = {
|
|
31
|
+
...baseChunk,
|
|
32
|
+
reload: async () => {
|
|
33
|
+
await fetchData();
|
|
34
|
+
},
|
|
35
|
+
mutate: (mutator) => {
|
|
36
|
+
const currentState = baseChunk.get();
|
|
37
|
+
const newData = mutator(currentState.data);
|
|
38
|
+
baseChunk.set({ ...currentState, data: newData });
|
|
39
|
+
},
|
|
40
|
+
reset: () => {
|
|
41
|
+
baseChunk.set(initialState);
|
|
42
|
+
fetchData();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return asyncChunkInstance;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/middleware/index.js
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function withPersistence(baseChunk, options) {
|
|
2
|
+
const { key, storage = localStorage, serialize = JSON.stringify, deserialize = JSON.parse, } = options;
|
|
3
|
+
// Try to load initial state from storage
|
|
4
|
+
try {
|
|
5
|
+
const savedChunk = storage.getItem(key);
|
|
6
|
+
if (savedChunk) {
|
|
7
|
+
const parsed = deserialize(savedChunk);
|
|
8
|
+
baseChunk.set(parsed);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
console.error('Failed to load persisted state:', error);
|
|
13
|
+
}
|
|
14
|
+
// Save to storage
|
|
15
|
+
baseChunk.subscribe((newValue) => {
|
|
16
|
+
try {
|
|
17
|
+
const serialized = serialize(newValue);
|
|
18
|
+
storage.setItem(key, serialized);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
console.log('Failed to persist chunk', error);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
return baseChunk;
|
|
25
|
+
}
|
package/dist/utils.js
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
import { chunk } from "./core/core";
|
|
1
2
|
export function isValidChunkValue(value) {
|
|
2
3
|
return value !== null && value !== undefined;
|
|
3
4
|
}
|
|
5
|
+
export function combineAsyncChunks(chunks) {
|
|
6
|
+
// Create initial state with proper typing
|
|
7
|
+
const initialData = Object.keys(chunks).reduce((acc, key) => {
|
|
8
|
+
acc[key] = null;
|
|
9
|
+
return acc;
|
|
10
|
+
}, {});
|
|
11
|
+
const initialState = {
|
|
12
|
+
loading: true,
|
|
13
|
+
error: null,
|
|
14
|
+
data: initialData
|
|
15
|
+
};
|
|
16
|
+
const combined = chunk(initialState);
|
|
17
|
+
Object.entries(chunks).forEach(([key, asyncChunk]) => {
|
|
18
|
+
asyncChunk.subscribe((state) => {
|
|
19
|
+
const currentState = combined.get();
|
|
20
|
+
combined.set({
|
|
21
|
+
loading: Object.values(chunks).some(chunk => chunk.get().loading),
|
|
22
|
+
error: Object.values(chunks)
|
|
23
|
+
.map(chunk => chunk.get().error)
|
|
24
|
+
.find(error => error !== null) || null,
|
|
25
|
+
data: {
|
|
26
|
+
...currentState.data,
|
|
27
|
+
[key]: state.data
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
return combined;
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stunk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Stunk - A framework-agnostic state management library implementing the Atomic State technique, utilizing chunk-based units for efficient state management.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/types/index.d.ts",
|
|
@@ -9,19 +9,25 @@
|
|
|
9
9
|
"test": "jest --runInBand",
|
|
10
10
|
"prepare": "npm run build"
|
|
11
11
|
},
|
|
12
|
+
"testEnvironment": "jsdom",
|
|
12
13
|
"keywords": [
|
|
13
14
|
"state-management",
|
|
14
15
|
"atomic-state",
|
|
15
16
|
"framework-agnostic",
|
|
16
17
|
"stunk",
|
|
17
18
|
"state",
|
|
18
|
-
"chunk"
|
|
19
|
+
"chunk",
|
|
20
|
+
"react",
|
|
21
|
+
"React state management",
|
|
22
|
+
"Vue state management",
|
|
23
|
+
"management"
|
|
19
24
|
],
|
|
20
25
|
"author": "AbdulAzeez",
|
|
21
26
|
"license": "MIT",
|
|
22
27
|
"devDependencies": {
|
|
23
28
|
"@types/jest": "^29.5.14",
|
|
24
29
|
"jest": "^29.7.0",
|
|
30
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
25
31
|
"ts-jest": "^29.2.5",
|
|
26
32
|
"typescript": "^5.0.0"
|
|
27
33
|
}
|
package/src/core/asyncChunk.ts
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { chunk, Chunk } from "./core";
|
|
2
|
+
import { AsyncChunkOpt } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface AsyncState<T> {
|
|
5
|
+
loading: boolean;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
data: T | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AsyncChunk<T> extends Chunk<AsyncState<T>> {
|
|
11
|
+
/**
|
|
12
|
+
* Reload the data from the source.
|
|
13
|
+
*/
|
|
14
|
+
reload: () => Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Mutate the data directly.
|
|
17
|
+
*/
|
|
18
|
+
mutate: (mutator: (currentData: T | null) => T) => void;
|
|
19
|
+
/**
|
|
20
|
+
* Reset the state to the initial value.
|
|
21
|
+
*/
|
|
22
|
+
reset: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function asyncChunk<T>(fetcher: () => Promise<T>, options: AsyncChunkOpt<T> = {}): AsyncChunk<T> {
|
|
26
|
+
const {
|
|
27
|
+
initialData = null,
|
|
28
|
+
onError,
|
|
29
|
+
retryCount = 0,
|
|
30
|
+
retryDelay = 1000,
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
const initialState: AsyncState<T> = {
|
|
34
|
+
loading: true,
|
|
35
|
+
error: null,
|
|
36
|
+
data: initialData,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const baseChunk = chunk(initialState);
|
|
40
|
+
|
|
41
|
+
const fetchData = async (retries = retryCount): Promise<void> => {
|
|
42
|
+
baseChunk.set({ ...baseChunk.get(), loading: true, error: null });
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const data = await fetcher();
|
|
46
|
+
baseChunk.set({ loading: false, error: null, data });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (retries > 0) {
|
|
49
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
50
|
+
return fetchData(retries - 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const errorObj = error instanceof Error ? error : new Error(String(error));
|
|
54
|
+
baseChunk.set({ loading: false, error: errorObj, data: baseChunk.get().data });
|
|
55
|
+
|
|
56
|
+
if (onError) {
|
|
57
|
+
onError(errorObj);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Initial fetch
|
|
64
|
+
fetchData();
|
|
65
|
+
|
|
66
|
+
const asyncChunkInstance: AsyncChunk<T> = {
|
|
67
|
+
...baseChunk,
|
|
68
|
+
reload: async () => {
|
|
69
|
+
await fetchData();
|
|
70
|
+
},
|
|
71
|
+
mutate: (mutator: (currentData: T | null) => T) => {
|
|
72
|
+
const currentState = baseChunk.get();
|
|
73
|
+
const newData = mutator(currentState.data);
|
|
74
|
+
baseChunk.set({ ...currentState, data: newData });
|
|
75
|
+
},
|
|
76
|
+
reset: () => {
|
|
77
|
+
baseChunk.set(initialState);
|
|
78
|
+
fetchData();
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return asyncChunkInstance;
|
|
83
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AsyncChunk } from "./asyncChunk";
|
|
2
|
+
|
|
3
|
+
export type AsyncChunkOpt<T> = {
|
|
4
|
+
initialData?: T | null;
|
|
5
|
+
onError?: (error: Error) => void;
|
|
6
|
+
retryCount?: number;
|
|
7
|
+
retryDelay?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type InferAsyncData<T> = T extends AsyncChunk<infer U> ? U : never;
|
|
11
|
+
|
|
12
|
+
export type CombinedData<T> = { [K in keyof T]: InferAsyncData<T[K]> | null };
|
|
13
|
+
export type CombinedState<T> = {
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error: Error | null;
|
|
16
|
+
data: CombinedData<T>;
|
|
17
|
+
};
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { Chunk } from "../core/core";
|
|
2
2
|
|
|
3
3
|
export interface ChunkWithHistory<T> extends Chunk<T> {
|
|
4
|
+
/**
|
|
5
|
+
* Reverts to the previous state (if available).
|
|
6
|
+
*/
|
|
4
7
|
undo: () => void;
|
|
8
|
+
/**
|
|
9
|
+
* Moves to the next state (if available).
|
|
10
|
+
*/
|
|
5
11
|
redo: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if there is a previous state to revert to.
|
|
14
|
+
*/
|
|
6
15
|
canUndo: () => boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Returns true if there is a next state to move to.
|
|
18
|
+
*/
|
|
7
19
|
canRedo: () => boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Returns an array of all the values in the history.
|
|
22
|
+
*/
|
|
8
23
|
getHistory: () => T[];
|
|
24
|
+
/**
|
|
25
|
+
* Clears the history, keeping only the current value.
|
|
26
|
+
*/
|
|
9
27
|
clearHistory: () => void;
|
|
10
28
|
}
|
|
11
29
|
|
package/src/middleware/index.ts
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Chunk } from "../core/core";
|
|
2
|
+
|
|
3
|
+
export interface PersistOptions<T> {
|
|
4
|
+
key: string;
|
|
5
|
+
storage?: Storage;
|
|
6
|
+
serialize?: (value: T) => string;
|
|
7
|
+
deserialize?: (value: string) => T;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function withPersistence<T>(
|
|
11
|
+
baseChunk: Chunk<T>,
|
|
12
|
+
options: PersistOptions<T>
|
|
13
|
+
): Chunk<T> {
|
|
14
|
+
const {
|
|
15
|
+
key,
|
|
16
|
+
storage = localStorage,
|
|
17
|
+
serialize = JSON.stringify,
|
|
18
|
+
deserialize = JSON.parse,
|
|
19
|
+
} = options;
|
|
20
|
+
|
|
21
|
+
// Try to load initial state from storage
|
|
22
|
+
try {
|
|
23
|
+
const savedChunk = storage.getItem(key);
|
|
24
|
+
if (savedChunk) {
|
|
25
|
+
const parsed = deserialize(savedChunk);
|
|
26
|
+
baseChunk.set(parsed)
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error('Failed to load persisted state:', error);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Save to storage
|
|
33
|
+
baseChunk.subscribe((newValue) => {
|
|
34
|
+
try {
|
|
35
|
+
const serialized = serialize(newValue);
|
|
36
|
+
storage.setItem(key, serialized);
|
|
37
|
+
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.log('Failed to persist chunk', error)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return baseChunk
|
|
44
|
+
|
|
45
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
+
import { chunk, Chunk } from "./core/core";
|
|
2
|
+
|
|
3
|
+
import { AsyncChunk } from "./core/asyncChunk";
|
|
4
|
+
import { CombinedData, CombinedState, InferAsyncData } from "./core/types";
|
|
5
|
+
|
|
1
6
|
export function isValidChunkValue(value: any): boolean {
|
|
2
7
|
return value !== null && value !== undefined;
|
|
3
8
|
}
|
|
9
|
+
|
|
10
|
+
export function combineAsyncChunks<T extends Record<string, AsyncChunk<any>>>(
|
|
11
|
+
chunks: T
|
|
12
|
+
): Chunk<{
|
|
13
|
+
loading: boolean;
|
|
14
|
+
error: Error | null;
|
|
15
|
+
data: { [K in keyof T]: InferAsyncData<T[K]> | null };
|
|
16
|
+
}> {
|
|
17
|
+
// Create initial state with proper typing
|
|
18
|
+
const initialData = Object.keys(chunks).reduce((acc, key) => {
|
|
19
|
+
acc[key as keyof T] = null;
|
|
20
|
+
return acc;
|
|
21
|
+
}, {} as CombinedData<T>);
|
|
22
|
+
|
|
23
|
+
const initialState: CombinedState<T> = {
|
|
24
|
+
loading: true,
|
|
25
|
+
error: null,
|
|
26
|
+
data: initialData
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const combined = chunk(initialState);
|
|
30
|
+
|
|
31
|
+
Object.entries(chunks).forEach(([key, asyncChunk]) => {
|
|
32
|
+
asyncChunk.subscribe((state) => {
|
|
33
|
+
const currentState = combined.get();
|
|
34
|
+
|
|
35
|
+
combined.set({
|
|
36
|
+
loading: Object.values(chunks).some(chunk => chunk.get().loading),
|
|
37
|
+
error: Object.values(chunks)
|
|
38
|
+
.map(chunk => chunk.get().error)
|
|
39
|
+
.find(error => error !== null) || null,
|
|
40
|
+
data: {
|
|
41
|
+
...currentState.data,
|
|
42
|
+
[key]: state.data
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return combined;
|
|
49
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { asyncChunk } from '../src/core/asyncChunk';
|
|
2
|
+
import { combineAsyncChunks } from '../src/utils';
|
|
3
|
+
|
|
4
|
+
// Mock types for testing
|
|
5
|
+
interface User {
|
|
6
|
+
id: number;
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Post {
|
|
11
|
+
id: number;
|
|
12
|
+
title: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('asyncChunk', () => {
|
|
16
|
+
// Helper to create a delayed response
|
|
17
|
+
const createDelayedResponse = <T>(data: T, delay = 50): Promise<T> => {
|
|
18
|
+
return new Promise((resolve) => setTimeout(() => resolve(data), delay));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
it('should handle successful async operations', async () => {
|
|
22
|
+
const mockUser: User = { id: 1, name: 'Test User' };
|
|
23
|
+
const userChunk = asyncChunk<User>(async () => {
|
|
24
|
+
return createDelayedResponse(mockUser);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Initial state
|
|
28
|
+
expect(userChunk.get()).toEqual({
|
|
29
|
+
loading: true,
|
|
30
|
+
error: null,
|
|
31
|
+
data: null
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Wait for async operation to complete
|
|
35
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
36
|
+
|
|
37
|
+
// Check final state
|
|
38
|
+
expect(userChunk.get()).toEqual({
|
|
39
|
+
loading: false,
|
|
40
|
+
error: null,
|
|
41
|
+
data: mockUser
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should handle errors', async () => {
|
|
46
|
+
const errorMessage = 'Failed to fetch';
|
|
47
|
+
const userChunk = asyncChunk<User>(async () => {
|
|
48
|
+
throw new Error(errorMessage);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Wait for async operation to complete
|
|
52
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
53
|
+
|
|
54
|
+
const state = userChunk.get();
|
|
55
|
+
expect(state.loading).toBe(false);
|
|
56
|
+
expect(state.error?.message).toBe(errorMessage);
|
|
57
|
+
expect(state.data).toBe(null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should handle retries', async () => {
|
|
61
|
+
let attempts = 0;
|
|
62
|
+
const mockUser: User = { id: 1, name: 'Test User' };
|
|
63
|
+
|
|
64
|
+
const userChunk = asyncChunk<User>(
|
|
65
|
+
async () => {
|
|
66
|
+
attempts++;
|
|
67
|
+
if (attempts < 3) {
|
|
68
|
+
throw new Error('Temporary error');
|
|
69
|
+
}
|
|
70
|
+
return mockUser;
|
|
71
|
+
},
|
|
72
|
+
{ retryCount: 2, retryDelay: 50 }
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Wait for all retries to complete
|
|
76
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
77
|
+
|
|
78
|
+
expect(attempts).toBe(3);
|
|
79
|
+
expect(userChunk.get().data).toEqual(mockUser);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should support optimistic updates via mutate', () => {
|
|
83
|
+
const mockUser: User = { id: 1, name: 'Test User' };
|
|
84
|
+
const userChunk = asyncChunk<User>(async () => mockUser);
|
|
85
|
+
|
|
86
|
+
userChunk.mutate(current => ({
|
|
87
|
+
...current!,
|
|
88
|
+
name: 'Updated Name'
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
const state = userChunk.get();
|
|
92
|
+
expect(state.data?.name).toBe('Updated Name');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should reload data when requested', async () => {
|
|
96
|
+
let counter = 0;
|
|
97
|
+
const userChunk = asyncChunk<User>(async () => {
|
|
98
|
+
counter++;
|
|
99
|
+
return { id: counter, name: `User ${counter}` };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Wait for initial load
|
|
103
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
104
|
+
expect(userChunk.get().data?.id).toBe(1);
|
|
105
|
+
|
|
106
|
+
// Trigger reload
|
|
107
|
+
await userChunk.reload();
|
|
108
|
+
expect(userChunk.get().data?.id).toBe(2);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('combineAsyncChunks', () => {
|
|
113
|
+
it('should combine multiple async chunks', async () => {
|
|
114
|
+
const mockUser: User = { id: 1, name: 'Test User' };
|
|
115
|
+
const mockPosts: Post[] = [
|
|
116
|
+
{ id: 1, title: 'Post 1' },
|
|
117
|
+
{ id: 2, title: 'Post 2' }
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const userChunk = asyncChunk<User>(async () => {
|
|
121
|
+
return createDelayedResponse(mockUser, 50);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const postsChunk = asyncChunk<Post[]>(async () => {
|
|
125
|
+
return createDelayedResponse(mockPosts, 100);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const combined = combineAsyncChunks({
|
|
129
|
+
user: userChunk,
|
|
130
|
+
posts: postsChunk
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Initial state
|
|
134
|
+
expect(combined.get()).toEqual({
|
|
135
|
+
loading: true,
|
|
136
|
+
error: null,
|
|
137
|
+
data: {
|
|
138
|
+
user: null,
|
|
139
|
+
posts: null
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Wait for all async operations to complete
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
145
|
+
|
|
146
|
+
// Check final state
|
|
147
|
+
expect(combined.get()).toEqual({
|
|
148
|
+
loading: false,
|
|
149
|
+
error: null,
|
|
150
|
+
data: {
|
|
151
|
+
user: mockUser,
|
|
152
|
+
posts: mockPosts
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should handle errors in combined chunks', async () => {
|
|
158
|
+
const mockUser: User = { id: 1, name: 'Test User' };
|
|
159
|
+
const errorMessage = 'Failed to fetch posts';
|
|
160
|
+
|
|
161
|
+
const userChunk = asyncChunk<User>(async () => {
|
|
162
|
+
return createDelayedResponse(mockUser, 50);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const postsChunk = asyncChunk<Post[]>(async () => {
|
|
166
|
+
throw new Error(errorMessage);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const combined = combineAsyncChunks({
|
|
170
|
+
user: userChunk,
|
|
171
|
+
posts: postsChunk
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Wait for all operations to complete
|
|
175
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
176
|
+
|
|
177
|
+
const state = combined.get();
|
|
178
|
+
expect(state.loading).toBe(false);
|
|
179
|
+
expect(state.error?.message).toBe(errorMessage);
|
|
180
|
+
expect(state.data.user).toEqual(mockUser);
|
|
181
|
+
expect(state.data.posts).toBe(null);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should update loading state correctly', async () => {
|
|
185
|
+
const loadingStates: boolean[] = [];
|
|
186
|
+
const userChunk = asyncChunk<User>(async () => {
|
|
187
|
+
return createDelayedResponse({ id: 1, name: 'Test User' }, 50);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const postsChunk = asyncChunk<Post[]>(async () => {
|
|
191
|
+
return createDelayedResponse([], 100);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const combined = combineAsyncChunks({
|
|
195
|
+
user: userChunk,
|
|
196
|
+
posts: postsChunk
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
combined.subscribe(state => {
|
|
200
|
+
loadingStates.push(state.loading);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Wait for all operations
|
|
204
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
205
|
+
|
|
206
|
+
// Should start with loading true and end with false
|
|
207
|
+
expect(loadingStates[0]).toBe(true);
|
|
208
|
+
expect(loadingStates[loadingStates.length - 1]).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Helper function
|
|
213
|
+
function createDelayedResponse<T>(data: T, delay = 50): Promise<T> {
|
|
214
|
+
return new Promise((resolve) => setTimeout(() => resolve(data), delay));
|
|
215
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { chunk } from '../src/core/core';
|
|
6
|
+
import { withPersistence } from "../src/middleware/persistence";
|
|
7
|
+
|
|
8
|
+
describe('withPersistence', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
localStorage.clear();
|
|
11
|
+
sessionStorage.clear();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should persist state to localStorage', () => {
|
|
15
|
+
const baseChunk = chunk({ count: 0 });
|
|
16
|
+
const persistedChunk = withPersistence(baseChunk, { key: 'test-key' });
|
|
17
|
+
|
|
18
|
+
persistedChunk.set({ count: 1 });
|
|
19
|
+
expect(JSON.parse(localStorage.getItem('test-key')!)).toEqual({ count: 1 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should load persisted state on initialization', () => {
|
|
23
|
+
localStorage.setItem('test-key', JSON.stringify({ count: 5 }));
|
|
24
|
+
|
|
25
|
+
const baseChunk = chunk({ count: 0 });
|
|
26
|
+
const persistedChunk = withPersistence(baseChunk, { key: 'test-key' });
|
|
27
|
+
|
|
28
|
+
expect(persistedChunk.get()).toEqual({ count: 5 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should use custom storage', () => {
|
|
32
|
+
const mockStorage = {
|
|
33
|
+
getItem: jest.fn(),
|
|
34
|
+
setItem: jest.fn(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const baseChunk = chunk({ count: 0 });
|
|
38
|
+
withPersistence(baseChunk, {
|
|
39
|
+
key: 'test-key',
|
|
40
|
+
storage: mockStorage as unknown as Storage
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(mockStorage.getItem).toHaveBeenCalledWith('test-key');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should use custom serializer/deserializer', () => {
|
|
47
|
+
const baseChunk = chunk({ count: 0 });
|
|
48
|
+
const persistedChunk = withPersistence(baseChunk, {
|
|
49
|
+
key: 'test-key',
|
|
50
|
+
serialize: value => btoa(JSON.stringify(value)),
|
|
51
|
+
deserialize: value => JSON.parse(atob(value))
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
persistedChunk.set({ count: 1 });
|
|
55
|
+
expect(localStorage.getItem('test-key')).toBe(btoa(JSON.stringify({ count: 1 })));
|
|
56
|
+
});
|
|
57
|
+
});
|