stunk 0.7.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 +216 -155
- package/dist/core/asyncChunk.js +46 -0
- 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,101 +1,62 @@
|
|
|
1
1
|
# Stunk
|
|
2
2
|
|
|
3
|
-
**Stunk** is a **framework-agnostic** state management library that helps you manage your application's state in a clean and simple way. It uses a technique called **Atomic State**, breaking down state into smaller **chunks** that are easy to update, subscribe to, and manage.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Pronunciation and Meaning
|
|
8
|
-
|
|
9
3
|
- **Pronunciation**: _Stunk_ (A playful blend of "state" and "chunk")
|
|
10
|
-
- **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.
|
|
11
4
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Think of your application's state as a big jar of data. In traditional state management, you keep everything in one big jar, and every time you want to change something, you have to dig through the whole jar.
|
|
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.
|
|
15
6
|
|
|
16
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.
|
|
17
8
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- lightweight, framework-agnostic state management.
|
|
21
|
-
- granular control over state updates and subscriptions.
|
|
22
|
-
- modular and composable state architecture.
|
|
9
|
+
## Features
|
|
23
10
|
|
|
24
|
-
|
|
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
|
|
25
20
|
|
|
26
21
|
## Installation
|
|
27
22
|
|
|
28
|
-
You can install **Stunk** from NPM:
|
|
29
|
-
|
|
30
23
|
```bash
|
|
31
24
|
npm install stunk
|
|
25
|
+
# or
|
|
26
|
+
yarn add stunk
|
|
32
27
|
```
|
|
33
28
|
|
|
34
|
-
##
|
|
29
|
+
## Basic Usage
|
|
35
30
|
|
|
36
|
-
|
|
31
|
+
A **chunk** is a small container of state. It holds a value, and you can do some stuffs with it:
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
- **Get** the current value of the chunk
|
|
41
|
-
- **Set** a new value for the chunk
|
|
42
|
-
- **Subscribe** to the chunk to get notified whenever the value changes
|
|
43
|
-
|
|
44
|
-
### Usage:
|
|
45
|
-
|
|
46
|
-
```ts
|
|
33
|
+
```typescript
|
|
47
34
|
import { chunk } from "stunk";
|
|
48
35
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
console.log(count.get()); // 0
|
|
52
|
-
|
|
53
|
-
count.set(5);
|
|
54
|
-
|
|
55
|
-
console.log(count.get()); // 5
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
### 1.1. **Subscription**
|
|
59
|
-
|
|
60
|
-
You can **subscribe** to a **chunk**. This means you get notified whenever the value inside the chunk changes. This is super useful for updating your app automatically when **state** changes.
|
|
61
|
-
|
|
62
|
-
### Usage
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
const count = chunk(0);
|
|
66
|
-
const callback = (newValue: number) => console.log("Updated value:", newValue);
|
|
67
|
-
|
|
68
|
-
count.subscribe(callback);
|
|
69
|
-
|
|
70
|
-
count.set(10); // Will log: "Updated value: 10"
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
### 1.1.0. **Unsubscribing**
|
|
74
|
-
|
|
75
|
-
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..** Well, would you wanna do that? 😂
|
|
76
|
-
|
|
77
|
-
### Usage
|
|
36
|
+
// Create a simple counter
|
|
37
|
+
const counterChunk = chunk(0);
|
|
78
38
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const unsubscribe = count.subscribe(callback);
|
|
39
|
+
// Subscribe to changes
|
|
40
|
+
counterChunk.subscribe((value) => {
|
|
41
|
+
console.log("Counter changed:", value);
|
|
42
|
+
});
|
|
84
43
|
|
|
85
|
-
|
|
44
|
+
// Update the value
|
|
45
|
+
counterChunk.set(1);
|
|
86
46
|
|
|
87
|
-
|
|
47
|
+
// Get current value
|
|
48
|
+
const value = counterChunk.get(); // 1
|
|
88
49
|
|
|
89
|
-
|
|
50
|
+
// Reset to initial value
|
|
51
|
+
counterChunk.reset();
|
|
90
52
|
```
|
|
91
53
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
With Stunk, you can create **derived chunks**. This means you can create a new **chunk** based on the value of another **chunk**. When the original **chunk** changes, the **derived chunk** will automatically update.
|
|
54
|
+
## Deriving New Chunks
|
|
95
55
|
|
|
96
|
-
|
|
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.
|
|
97
58
|
|
|
98
|
-
```
|
|
59
|
+
```typescript
|
|
99
60
|
const count = chunk(5);
|
|
100
61
|
|
|
101
62
|
// Create a derived chunk that doubles the count
|
|
@@ -110,138 +71,238 @@ count.set(10);
|
|
|
110
71
|
// "Double count: 20"
|
|
111
72
|
```
|
|
112
73
|
|
|
113
|
-
|
|
74
|
+
## Batch Updates
|
|
114
75
|
|
|
115
|
-
Batch
|
|
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.
|
|
116
77
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
```ts
|
|
78
|
+
```typescript
|
|
120
79
|
import { chunk, batch } from "stunk";
|
|
121
80
|
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const age = chunk(25);
|
|
81
|
+
const nameChunk = chunk("Olamide");
|
|
82
|
+
const ageChunk = chunk(30);
|
|
125
83
|
|
|
126
|
-
// Subscribe to changes
|
|
127
|
-
firstName.subscribe((name) => console.log("First name changed:", name));
|
|
128
|
-
lastName.subscribe((name) => console.log("Last name changed:", name));
|
|
129
|
-
age.subscribe((age) => console.log("Age changed:", age));
|
|
130
|
-
|
|
131
|
-
// Without batch - triggers three separate updates
|
|
132
|
-
firstName.set("Jane"); // Logs immediately
|
|
133
|
-
lastName.set("Smith"); // Logs immediately
|
|
134
|
-
age.set(26); // Logs immediately
|
|
135
|
-
|
|
136
|
-
// With batch - triggers only one update per chunk at the end
|
|
137
84
|
batch(() => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}); // All logs happen here at once
|
|
85
|
+
nameChunk.set("AbdulAzeez");
|
|
86
|
+
ageChunk.set(31);
|
|
87
|
+
}); // Only one notification will be sent to subscribers
|
|
142
88
|
|
|
143
89
|
// Nested batches are also supported
|
|
144
90
|
batch(() => {
|
|
145
|
-
firstName.set("
|
|
91
|
+
firstName.set("Olanrewaju");
|
|
146
92
|
batch(() => {
|
|
147
|
-
|
|
148
|
-
age.set(26);
|
|
93
|
+
age.set(29);
|
|
149
94
|
});
|
|
150
|
-
});
|
|
95
|
+
}); // Only one notification will be sent to subscribers
|
|
151
96
|
```
|
|
152
97
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
Batching is particularly useful when:
|
|
156
|
-
|
|
157
|
-
- Updating multiple related pieces of state at once
|
|
158
|
-
- Performing form updates
|
|
159
|
-
- Handling complex state transitions
|
|
160
|
-
- Optimizing performance in data-heavy applications
|
|
98
|
+
## State Selection
|
|
161
99
|
|
|
162
|
-
|
|
100
|
+
Efficiently access and react to specific state parts:
|
|
163
101
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
- Nested batches are handled correctly
|
|
167
|
-
- Updates are processed even if an error occurs (using try/finally)
|
|
102
|
+
```typescript
|
|
103
|
+
import { chunk, select } from "stunk";
|
|
168
104
|
|
|
169
|
-
|
|
105
|
+
const userChunk = chunk({
|
|
106
|
+
name: "Olamide",
|
|
107
|
+
age: 30,
|
|
108
|
+
email: "olamide@example.com",
|
|
109
|
+
});
|
|
170
110
|
|
|
171
|
-
|
|
111
|
+
// Select specific properties -readonly
|
|
112
|
+
const nameChunk = select(userChunk, (state) => state.name);
|
|
113
|
+
const ageChunk = select(userChunk, (state) => state.age);
|
|
172
114
|
|
|
173
|
-
|
|
115
|
+
nameChunk.subscribe((name) => console.log("Name changed:", name));
|
|
116
|
+
// will only re-render if the selected part change.
|
|
174
117
|
|
|
175
|
-
|
|
176
|
-
export type Middleware<T> = (value: T, next: (newValue: T) => void) => void;
|
|
118
|
+
nameChunk.set("Olamide"); // ❌ this will throw an error, because it is a readonly.
|
|
177
119
|
```
|
|
178
120
|
|
|
179
|
-
|
|
180
|
-
- next(value): A function you must call with the processed (or unaltered) value to continue the chain of middleware and eventually update the chunk's state.
|
|
121
|
+
## Middleware
|
|
181
122
|
|
|
182
|
-
|
|
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.
|
|
183
124
|
|
|
184
|
-
```
|
|
125
|
+
```typescript
|
|
185
126
|
import { chunk } from "stunk";
|
|
186
|
-
import { logger } from "stunk/middleware";
|
|
187
|
-
|
|
188
|
-
// You can also create yours and pass it
|
|
127
|
+
import { logger, nonNegativeValidator } from "stunk/middleware";
|
|
128
|
+
|
|
129
|
+
// You can also create yours and pass it chunk as the second param
|
|
189
130
|
|
|
190
131
|
// Use middleware for logging and validation
|
|
191
132
|
const age = chunk(25, [logger, nonNegativeValidator]);
|
|
192
133
|
|
|
193
134
|
age.set(30); // Logs: "Setting value: 30"
|
|
194
|
-
age.set(-5); // Throws an error: "Value must be non-negative!"
|
|
135
|
+
age.set(-5); // ❌ Throws an error: "Value must be non-negative!"
|
|
195
136
|
```
|
|
196
137
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
The **withHistory** middleware extends a chunk to support undo and redo functionality. This allows you to navigate back and forth between previous **states**, making it useful for implementing features like **undo/redo**, form history, and state time travel.
|
|
200
|
-
|
|
201
|
-
### Usage
|
|
138
|
+
## Time Travel (Middleware)
|
|
202
139
|
|
|
203
|
-
```
|
|
140
|
+
```typescript
|
|
204
141
|
import { chunk } from "stunk";
|
|
205
|
-
import { withHistory } from "stunk/
|
|
142
|
+
import { withHistory } from "stunk/midddleware";
|
|
206
143
|
|
|
207
|
-
const
|
|
144
|
+
const counterChunk = withHistory(chunk(0));
|
|
208
145
|
|
|
209
|
-
|
|
210
|
-
|
|
146
|
+
counterChunk.set(1);
|
|
147
|
+
counterChunk.set(2);
|
|
211
148
|
|
|
212
|
-
|
|
149
|
+
counterChunk.undo(); // Goes back to 1
|
|
150
|
+
counterChunk.undo(); // Goes back to 0
|
|
213
151
|
|
|
214
|
-
|
|
215
|
-
console.log(counter.get()); // 10
|
|
152
|
+
counterChunk.redo(); // Goes forward to 1
|
|
216
153
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
```
|
|
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.
|
|
220
156
|
|
|
221
|
-
|
|
157
|
+
counterChunk.getHistory(); // Returns an array of all the values in the history.
|
|
222
158
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
| `undo()` | Reverts to the previous state (if available). |
|
|
226
|
-
| `redo()` | Moves forward to the next state (if available). |
|
|
227
|
-
| `canUndo()` | Returns `true` if there are past states available. |
|
|
228
|
-
| `canRedo()` | Returns `true` if there are future states available. |
|
|
229
|
-
| `getHistory()` | Returns an `array` of all past states. |
|
|
230
|
-
| `clearHistory()` | Clears all stored history and keeps only the current state. |
|
|
159
|
+
counterChunk.clearHistory(); // Clears the history, keeping only the current value.
|
|
160
|
+
```
|
|
231
161
|
|
|
232
162
|
**Example: Limiting History Size (Optional)**
|
|
233
163
|
You can specify a max history size to prevent excessive memory usage.
|
|
234
164
|
|
|
235
165
|
```ts
|
|
236
|
-
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.
|
|
237
168
|
```
|
|
238
169
|
|
|
239
170
|
This prevents the history from growing indefinitely and ensures efficient memory usage.
|
|
240
171
|
|
|
241
|
-
##
|
|
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
|
+
|
|
237
|
+
## API Reference
|
|
238
|
+
|
|
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>`
|
|
253
|
+
|
|
254
|
+
### Types
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
interface Chunk<T> {
|
|
258
|
+
get(): T;
|
|
259
|
+
set(value: T): void;
|
|
260
|
+
subscribe(callback: (value: T) => void): () => void;
|
|
261
|
+
derive<D>(fn: (value: T) => D): Chunk<D>;
|
|
262
|
+
reset(): void;
|
|
263
|
+
destroy(): void;
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
interface AsyncState<T> {
|
|
269
|
+
loading: boolean;
|
|
270
|
+
error: Error | null;
|
|
271
|
+
data: T | null;
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
interface AsyncChunk<T> extends Chunk<AsyncState<T>> {
|
|
277
|
+
reload(): Promise<void>;
|
|
278
|
+
mutate(mutator: (currentData: T | null) => T): void;
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
interface ChunkWithHistory<T> extends Chunk<T> {
|
|
284
|
+
undo: () => void;
|
|
285
|
+
redo: () => void;
|
|
286
|
+
canUndo: () => boolean;
|
|
287
|
+
canRedo: () => boolean;
|
|
288
|
+
getHistory: () => T[];
|
|
289
|
+
clearHistory: () => void;
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
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
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Contributing
|
|
303
|
+
|
|
304
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
242
305
|
|
|
243
|
-
|
|
306
|
+
## License
|
|
244
307
|
|
|
245
|
-
|
|
246
|
-
- Update only the parts of your app that need to change
|
|
247
|
-
- Easily manage and subscribe to state changes
|
|
308
|
+
This is licence under MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
}
|
|
@@ -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
|
+
});
|