stunk 0.6.0 → 0.8.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 +147 -104
- package/dist/core/asyncChunk.js +1 -0
- package/dist/{core.js → core/core.js} +22 -0
- package/dist/index.js +2 -3
- package/dist/middleware/history.js +58 -0
- package/dist/middleware/index.js +3 -0
- package/package.json +1 -1
- package/src/core/asyncChunk.ts +0 -0
- package/src/{core.ts → core/core.ts} +26 -0
- package/src/index.ts +3 -4
- package/src/middleware/history.ts +86 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/logger.ts +1 -1
- package/src/middleware/validator.ts +1 -1
- package/tests/batch-chunk.test.ts +1 -1
- package/tests/chunk.test.ts +1 -1
- package/tests/history.test.ts +99 -0
- package/tests/middleware.test.ts +1 -1
- package/tests/select-chunk.test.ts +132 -0
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# Stunk
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
---
|
|
3
|
+
A lightweight, framework-agnostic state management library using atomic state principles. Stunk breaks down state into manageable "chunks" for easy updates and subscriptions.
|
|
6
4
|
|
|
7
5
|
## Pronunciation and Meaning
|
|
8
6
|
|
|
@@ -15,49 +13,61 @@ Think of your application's state as a big jar of data. In traditional state man
|
|
|
15
13
|
|
|
16
14
|
**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
15
|
|
|
18
|
-
|
|
16
|
+
## Features
|
|
19
17
|
|
|
20
|
-
-
|
|
21
|
-
- granular control over state updates and subscriptions.
|
|
22
|
-
- modular and composable state architecture.
|
|
18
|
+
<!-- - 🎯 Framework agnostic -->
|
|
23
19
|
|
|
24
|
-
|
|
20
|
+
- 🔄 Reactive updates with efficient subscription system
|
|
21
|
+
- 🎯 Granular state selection
|
|
22
|
+
- ⏳ Built-in undo/redo/getHistory/clearHistory
|
|
23
|
+
- 🔄 Batch updates for performance and nested batch updates
|
|
24
|
+
- 🛠️ Extensible middleware
|
|
25
|
+
- 🔍 Full TypeScript support
|
|
25
26
|
|
|
26
27
|
## Installation
|
|
27
28
|
|
|
28
|
-
You can install **Stunk** from NPM:
|
|
29
|
-
|
|
30
29
|
```bash
|
|
31
30
|
npm install stunk
|
|
32
31
|
```
|
|
33
32
|
|
|
34
|
-
##
|
|
33
|
+
## Quick Start
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
```typescript
|
|
36
|
+
import { chunk, select, batch } from "stunk";
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// Create a state chunk
|
|
39
|
+
const counter = chunk(0);
|
|
40
|
+
const userChunk = chunk({ name: "Olamide", age: 26 });
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- **Subscribe** to the chunk to get notified whenever the value changes
|
|
42
|
+
// Select specific state - Selector
|
|
43
|
+
const nameSelector = select(userChunk, (user) => user.name);
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
// Subscribe to changes
|
|
46
|
+
nameSelector.subscribe((name) => console.log("Name:", name));
|
|
47
|
+
counter.subscribe((count) => console.log("Counter", counter));
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
```
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
## Core Concepts
|
|
50
57
|
|
|
51
|
-
|
|
58
|
+
### Chunks
|
|
52
59
|
|
|
53
|
-
|
|
60
|
+
Basic unit of state with get/set/subscribe functionality:
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
```typescript
|
|
63
|
+
const counter = chunk(0);
|
|
64
|
+
counter.subscribe((value) => console.log(value));
|
|
65
|
+
counter.set(5);
|
|
56
66
|
```
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
## Unsubscribing
|
|
59
69
|
|
|
60
|
-
You can **
|
|
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..**
|
|
61
71
|
|
|
62
72
|
### Usage
|
|
63
73
|
|
|
@@ -65,18 +75,20 @@ You can **subscribe** to a **chunk**. This means you get notified whenever the v
|
|
|
65
75
|
const count = chunk(0);
|
|
66
76
|
const callback = (newValue: number) => console.log("Updated value:", newValue);
|
|
67
77
|
|
|
68
|
-
count.subscribe(callback);
|
|
78
|
+
const unsubscribe = count.subscribe(callback);
|
|
69
79
|
|
|
70
80
|
count.set(10); // Will log: "Updated value: 10"
|
|
81
|
+
|
|
82
|
+
unsubscribe(); // Unsubscribe
|
|
83
|
+
|
|
84
|
+
count.set(20); // Nothing will happen now, because you unsubscribed
|
|
71
85
|
```
|
|
72
86
|
|
|
73
|
-
###
|
|
87
|
+
### Deriving New Chunks
|
|
74
88
|
|
|
75
89
|
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.
|
|
76
90
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
```ts
|
|
91
|
+
```typescript
|
|
80
92
|
const count = chunk(5);
|
|
81
93
|
|
|
82
94
|
// Create a derived chunk that doubles the count
|
|
@@ -91,113 +103,144 @@ count.set(10);
|
|
|
91
103
|
// "Double count: 20"
|
|
92
104
|
```
|
|
93
105
|
|
|
94
|
-
###
|
|
106
|
+
### Batch Updates
|
|
95
107
|
|
|
96
|
-
|
|
108
|
+
Group multiple updates:
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
```typescript
|
|
111
|
+
batch(() => {
|
|
112
|
+
chunk1.set(newValue1);
|
|
113
|
+
chunk2.set(newValue2);
|
|
114
|
+
}); // Single notification
|
|
103
115
|
|
|
104
|
-
|
|
116
|
+
// Nested batches are also supported
|
|
117
|
+
batch(() => {
|
|
118
|
+
chunk1.set("Tunde");
|
|
119
|
+
batch(() => {
|
|
120
|
+
chunk1.set(26);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
```
|
|
105
124
|
|
|
106
|
-
|
|
125
|
+
### Selectors
|
|
107
126
|
|
|
108
|
-
|
|
127
|
+
Efficiently access and react to specific state parts:
|
|
109
128
|
|
|
110
|
-
|
|
129
|
+
```typescript
|
|
130
|
+
// With selector - more specific, read-only
|
|
131
|
+
const userChunk = chunk({ name: "Olamide", score: 100 });
|
|
132
|
+
const scoreSelector = select(userChunk, (u) => u.score);
|
|
133
|
+
// scoreSelector.set(200); // This would throw an error
|
|
111
134
|
```
|
|
112
135
|
|
|
113
|
-
|
|
136
|
+
## Middleware
|
|
114
137
|
|
|
115
|
-
|
|
138
|
+
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.
|
|
116
139
|
|
|
117
|
-
|
|
140
|
+
```typescript
|
|
141
|
+
// You can also create yours and pass it []
|
|
118
142
|
|
|
119
|
-
|
|
120
|
-
|
|
143
|
+
// Use middleware for logging and validation
|
|
144
|
+
const age = chunk(25, [logger, nonNegativeValidator]);
|
|
121
145
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
146
|
+
age.set(30); // Logs: "Setting value: 30"
|
|
147
|
+
age.set(-5); // Throws an error: "Value must be non-negative!"
|
|
148
|
+
```
|
|
125
149
|
|
|
126
|
-
|
|
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));
|
|
150
|
+
### History (Undo/Redo) - Time Travel
|
|
130
151
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
lastName.set("Smith"); // Logs immediately
|
|
134
|
-
age.set(26); // Logs immediately
|
|
152
|
+
```typescript
|
|
153
|
+
const counter = withHistory(chunk(0));
|
|
135
154
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
firstName.set("Jane"); // Doesn't log yet
|
|
139
|
-
lastName.set("Smith"); // Doesn't log yet
|
|
140
|
-
age.set(26); // Doesn't log yet
|
|
141
|
-
}); // All logs happen here at once
|
|
155
|
+
counter.set(10);
|
|
156
|
+
counter.set(20);
|
|
142
157
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
});
|
|
158
|
+
console.log(counter.get()); // 20
|
|
159
|
+
|
|
160
|
+
counter.undo(); // Go back one step
|
|
161
|
+
console.log(counter.get()); // 10
|
|
162
|
+
|
|
163
|
+
counter.redo(); // Go forward one step
|
|
164
|
+
console.log(counter.get()); // 20
|
|
151
165
|
```
|
|
152
166
|
|
|
153
|
-
|
|
167
|
+
**Example: Limiting History Size (Optional)**
|
|
168
|
+
You can specify a max history size to prevent excessive memory usage.
|
|
154
169
|
|
|
155
|
-
|
|
170
|
+
```ts
|
|
171
|
+
const counter = withHistory(chunk(0), { maxHistory: 5 }); // Only keeps the last 5 changes -- default is 100.
|
|
172
|
+
```
|
|
156
173
|
|
|
157
|
-
|
|
158
|
-
- Performing form updates
|
|
159
|
-
- Handling complex state transitions
|
|
160
|
-
- Optimizing performance in data-heavy applications
|
|
174
|
+
This prevents the history from growing indefinitely and ensures efficient memory usage.
|
|
161
175
|
|
|
162
|
-
|
|
176
|
+
## API Reference
|
|
163
177
|
|
|
164
|
-
|
|
165
|
-
- Subscribers are notified only once with the final value
|
|
166
|
-
- Nested batches are handled correctly
|
|
167
|
-
- Updates are processed even if an error occurs (using try/finally)
|
|
178
|
+
### `chunk<T>(initialValue: T, middleware?: Middleware<T>[])`
|
|
168
179
|
|
|
169
|
-
|
|
180
|
+
Creates a new state chunk.
|
|
170
181
|
|
|
171
|
-
|
|
182
|
+
```typescript
|
|
183
|
+
interface Chunk<T> {
|
|
184
|
+
get(): T;
|
|
185
|
+
set(value: T): void;
|
|
186
|
+
subscribe(callback: (value: T) => void): () => void;
|
|
187
|
+
derive<D>(fn: (value: T) => D): Chunk<D>;
|
|
188
|
+
destroy(): void;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
172
191
|
|
|
173
|
-
|
|
192
|
+
### `select<T, S>(sourceChunk: Chunk<T>, selector: (value: T) => S)`
|
|
174
193
|
|
|
175
|
-
|
|
176
|
-
|
|
194
|
+
Creates an optimized selector.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// Returns a read-only chunk that updates only when selected value changes
|
|
198
|
+
const selector = select(userChunk, (user) => user.name);
|
|
177
199
|
```
|
|
178
200
|
|
|
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.
|
|
201
|
+
### `batch(callback: () => void)`
|
|
181
202
|
|
|
182
|
-
|
|
203
|
+
Batches multiple updates.
|
|
183
204
|
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
// You can also create yours and pass it []
|
|
205
|
+
```typescript
|
|
206
|
+
batch(() => {
|
|
207
|
+
// Multiple updates here
|
|
208
|
+
});
|
|
189
209
|
|
|
190
|
-
|
|
191
|
-
|
|
210
|
+
batch(() => {
|
|
211
|
+
// Multiple updates here
|
|
212
|
+
batch(() => {
|
|
213
|
+
// Nested upddates here
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
```
|
|
192
217
|
|
|
193
|
-
|
|
194
|
-
|
|
218
|
+
### `withHistory<T>(chunk: Chunk<T>, options?: { maxHistory?: number })`
|
|
219
|
+
|
|
220
|
+
Adds undo/redo capabilities.
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
interface ChunkWithHistory<T> extends Chunk<T> {
|
|
224
|
+
undo(): void; // Reverts to the previous state (if available).
|
|
225
|
+
redo(): void; // Moves forward to the next state (if available).
|
|
226
|
+
canUndo(): boolean; // Returns `true` if there are past states available.
|
|
227
|
+
canRedo(): boolean; // Returns `true` if there are future states available.
|
|
228
|
+
getHistory(): T[]; // Returns an `array` of all past states.
|
|
229
|
+
clearHistory(): void; // Clears all stored history and keeps only the current state.
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### `Middleware<T>`
|
|
234
|
+
|
|
235
|
+
Custom state processing:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
type Middleware<T> = (value: T, next: (newValue: T) => void) => void;
|
|
195
239
|
```
|
|
196
240
|
|
|
197
|
-
|
|
241
|
+
- value: The value that is about to be set to the chunk.
|
|
242
|
+
- 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.
|
|
198
243
|
|
|
199
|
-
|
|
244
|
+
## License
|
|
200
245
|
|
|
201
|
-
|
|
202
|
-
- Update only the parts of your app that need to change
|
|
203
|
-
- Easily manage and subscribe to state changes
|
|
246
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";
|
|
@@ -14,6 +14,28 @@ export function batch(callback) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
export function select(sourceChunk, selector) {
|
|
18
|
+
const initialValue = selector(sourceChunk.get());
|
|
19
|
+
const selectedChunk = chunk(initialValue);
|
|
20
|
+
let previousSelected = initialValue;
|
|
21
|
+
// Subscribe to source changes with equality checking
|
|
22
|
+
sourceChunk.subscribe((newValue) => {
|
|
23
|
+
const newSelected = selector(newValue);
|
|
24
|
+
// Only update if the selected value actually changed
|
|
25
|
+
if (!Object.is(newSelected, previousSelected)) {
|
|
26
|
+
previousSelected = newSelected;
|
|
27
|
+
selectedChunk.set(newSelected);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
// Return read-only version of the chunk
|
|
31
|
+
return {
|
|
32
|
+
...selectedChunk,
|
|
33
|
+
// Prevent setting values directly on the selector
|
|
34
|
+
set: () => {
|
|
35
|
+
throw new Error('Cannot set values directly on a selector. Modify the source chunk instead.');
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
17
39
|
export function chunk(initialValue, middleware = []) {
|
|
18
40
|
if (initialValue === undefined || initialValue === null) {
|
|
19
41
|
throw new Error("Initial value cannot be undefined or null.");
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
export { chunk } from './core';
|
|
2
|
-
export
|
|
3
|
-
export { nonNegativeValidator } from "./middleware/validator";
|
|
1
|
+
export { chunk } from './core/core';
|
|
2
|
+
export * from "./middleware";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export function withHistory(baseChunk, options = {}) {
|
|
2
|
+
const { maxHistory = 100 } = options;
|
|
3
|
+
const history = [baseChunk.get()];
|
|
4
|
+
let currentIndex = 0;
|
|
5
|
+
let isHistoryAction = false;
|
|
6
|
+
const historyChunk = {
|
|
7
|
+
...baseChunk,
|
|
8
|
+
set: (newValue) => {
|
|
9
|
+
if (isHistoryAction) {
|
|
10
|
+
baseChunk.set(newValue);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
// Remove any future history when setting a new value
|
|
14
|
+
history.splice(currentIndex + 1);
|
|
15
|
+
history.push(newValue);
|
|
16
|
+
// Limit history size
|
|
17
|
+
if (history.length > maxHistory) {
|
|
18
|
+
console.warn("History limit reached. Removing oldest entries.");
|
|
19
|
+
const removeCount = history.length - maxHistory;
|
|
20
|
+
history.splice(0, removeCount);
|
|
21
|
+
currentIndex = Math.max(0, currentIndex - removeCount);
|
|
22
|
+
}
|
|
23
|
+
currentIndex = history.length - 1;
|
|
24
|
+
baseChunk.set(newValue);
|
|
25
|
+
},
|
|
26
|
+
undo: () => {
|
|
27
|
+
if (!historyChunk.canUndo())
|
|
28
|
+
return;
|
|
29
|
+
isHistoryAction = true;
|
|
30
|
+
currentIndex--;
|
|
31
|
+
historyChunk.set(history[currentIndex]);
|
|
32
|
+
isHistoryAction = false;
|
|
33
|
+
},
|
|
34
|
+
redo: () => {
|
|
35
|
+
if (!historyChunk.canRedo())
|
|
36
|
+
return;
|
|
37
|
+
isHistoryAction = true;
|
|
38
|
+
currentIndex++;
|
|
39
|
+
historyChunk.set(history[currentIndex]);
|
|
40
|
+
isHistoryAction = false;
|
|
41
|
+
},
|
|
42
|
+
canUndo: () => currentIndex > 0,
|
|
43
|
+
canRedo: () => currentIndex < history.length - 1,
|
|
44
|
+
getHistory: () => [...history],
|
|
45
|
+
clearHistory: () => {
|
|
46
|
+
const currentValue = baseChunk.get();
|
|
47
|
+
history.length = 0;
|
|
48
|
+
history.push(currentValue);
|
|
49
|
+
currentIndex = 0;
|
|
50
|
+
},
|
|
51
|
+
// Override destroy to clean up history
|
|
52
|
+
destroy: () => {
|
|
53
|
+
history.length = 0;
|
|
54
|
+
baseChunk.destroy();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
return historyChunk;
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stunk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
File without changes
|
|
@@ -34,6 +34,32 @@ export function batch(callback: () => void) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
export function select<T, S>(sourceChunk: Chunk<T>, selector: (value: T) => S): Chunk<S> {
|
|
38
|
+
const initialValue = selector(sourceChunk.get());
|
|
39
|
+
const selectedChunk = chunk(initialValue);
|
|
40
|
+
let previousSelected = initialValue;
|
|
41
|
+
|
|
42
|
+
// Subscribe to source changes with equality checking
|
|
43
|
+
sourceChunk.subscribe((newValue) => {
|
|
44
|
+
const newSelected = selector(newValue);
|
|
45
|
+
|
|
46
|
+
// Only update if the selected value actually changed
|
|
47
|
+
if (!Object.is(newSelected, previousSelected)) {
|
|
48
|
+
previousSelected = newSelected;
|
|
49
|
+
selectedChunk.set(newSelected);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Return read-only version of the chunk
|
|
54
|
+
return {
|
|
55
|
+
...selectedChunk,
|
|
56
|
+
// Prevent setting values directly on the selector
|
|
57
|
+
set: () => {
|
|
58
|
+
throw new Error('Cannot set values directly on a selector. Modify the source chunk instead.');
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
export function chunk<T>(initialValue: T, middleware: Middleware<T>[] = []): Chunk<T> {
|
|
38
64
|
if (initialValue === undefined || initialValue === null) {
|
|
39
65
|
throw new Error("Initial value cannot be undefined or null.");
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export { chunk } from './core';
|
|
2
|
-
export type { Chunk } from './core';
|
|
1
|
+
export { chunk } from './core/core';
|
|
2
|
+
export type { Chunk } from './core/core';
|
|
3
3
|
|
|
4
|
-
export
|
|
5
|
-
export { nonNegativeValidator } from "./middleware/validator";
|
|
4
|
+
export * from "./middleware";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Chunk } from "../core/core";
|
|
2
|
+
|
|
3
|
+
export interface ChunkWithHistory<T> extends Chunk<T> {
|
|
4
|
+
undo: () => void;
|
|
5
|
+
redo: () => void;
|
|
6
|
+
canUndo: () => boolean;
|
|
7
|
+
canRedo: () => boolean;
|
|
8
|
+
getHistory: () => T[];
|
|
9
|
+
clearHistory: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function withHistory<T>(
|
|
13
|
+
baseChunk: Chunk<T>,
|
|
14
|
+
options: { maxHistory?: number } = {}
|
|
15
|
+
): ChunkWithHistory<T> {
|
|
16
|
+
const { maxHistory = 100 } = options;
|
|
17
|
+
const history: T[] = [baseChunk.get()];
|
|
18
|
+
let currentIndex = 0;
|
|
19
|
+
let isHistoryAction = false;
|
|
20
|
+
|
|
21
|
+
const historyChunk: ChunkWithHistory<T> = {
|
|
22
|
+
...baseChunk,
|
|
23
|
+
|
|
24
|
+
set: (newValue: T) => {
|
|
25
|
+
if (isHistoryAction) {
|
|
26
|
+
baseChunk.set(newValue);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Remove any future history when setting a new value
|
|
31
|
+
history.splice(currentIndex + 1);
|
|
32
|
+
history.push(newValue);
|
|
33
|
+
|
|
34
|
+
// Limit history size
|
|
35
|
+
if (history.length > maxHistory) {
|
|
36
|
+
console.warn("History limit reached. Removing oldest entries.");
|
|
37
|
+
const removeCount = history.length - maxHistory;
|
|
38
|
+
history.splice(0, removeCount);
|
|
39
|
+
currentIndex = Math.max(0, currentIndex - removeCount);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
currentIndex = history.length - 1;
|
|
43
|
+
baseChunk.set(newValue);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
undo: () => {
|
|
47
|
+
if (!historyChunk.canUndo()) return;
|
|
48
|
+
|
|
49
|
+
isHistoryAction = true;
|
|
50
|
+
currentIndex--;
|
|
51
|
+
historyChunk.set(history[currentIndex]);
|
|
52
|
+
isHistoryAction = false;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
redo: () => {
|
|
56
|
+
if (!historyChunk.canRedo()) return;
|
|
57
|
+
|
|
58
|
+
isHistoryAction = true;
|
|
59
|
+
currentIndex++;
|
|
60
|
+
historyChunk.set(history[currentIndex]);
|
|
61
|
+
isHistoryAction = false;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
canUndo: () => currentIndex > 0,
|
|
65
|
+
|
|
66
|
+
canRedo: () => currentIndex < history.length - 1,
|
|
67
|
+
|
|
68
|
+
getHistory: () => [...history],
|
|
69
|
+
|
|
70
|
+
clearHistory: () => {
|
|
71
|
+
const currentValue = baseChunk.get();
|
|
72
|
+
history.length = 0;
|
|
73
|
+
history.push(currentValue);
|
|
74
|
+
currentIndex = 0;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Override destroy to clean up history
|
|
78
|
+
destroy: () => {
|
|
79
|
+
history.length = 0;
|
|
80
|
+
baseChunk.destroy();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return historyChunk;
|
|
85
|
+
|
|
86
|
+
}
|
package/src/middleware/logger.ts
CHANGED
package/tests/chunk.test.ts
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { batch, chunk } from "../src/core/core";
|
|
2
|
+
import { withHistory } from "../src/middleware/history";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('Chunk with History', () => {
|
|
6
|
+
it('should maintain history of changes', () => {
|
|
7
|
+
const baseChunk = chunk(0);
|
|
8
|
+
const historyChunk = withHistory(baseChunk);
|
|
9
|
+
|
|
10
|
+
historyChunk.set(1);
|
|
11
|
+
historyChunk.set(2);
|
|
12
|
+
historyChunk.set(3);
|
|
13
|
+
|
|
14
|
+
expect(historyChunk.getHistory()).toEqual([0, 1, 2, 3]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should handle undo and redo operations', () => {
|
|
18
|
+
const baseChunk = chunk(0);
|
|
19
|
+
const historyChunk = withHistory(baseChunk);
|
|
20
|
+
const callback = jest.fn();
|
|
21
|
+
|
|
22
|
+
historyChunk.subscribe(callback);
|
|
23
|
+
callback.mockClear(); // Clear initial subscription call
|
|
24
|
+
|
|
25
|
+
historyChunk.set(1);
|
|
26
|
+
historyChunk.set(2);
|
|
27
|
+
|
|
28
|
+
expect(historyChunk.get()).toBe(2);
|
|
29
|
+
|
|
30
|
+
historyChunk.undo();
|
|
31
|
+
expect(historyChunk.get()).toBe(1);
|
|
32
|
+
|
|
33
|
+
historyChunk.undo();
|
|
34
|
+
expect(historyChunk.get()).toBe(0);
|
|
35
|
+
|
|
36
|
+
historyChunk.redo();
|
|
37
|
+
expect(historyChunk.get()).toBe(1);
|
|
38
|
+
|
|
39
|
+
historyChunk.redo();
|
|
40
|
+
expect(historyChunk.get()).toBe(2);
|
|
41
|
+
|
|
42
|
+
expect(callback).toHaveBeenCalledTimes(6); // 2 sets + 2 undos + 2 redos
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should handle branching history', () => {
|
|
46
|
+
const baseChunk = chunk(0);
|
|
47
|
+
const historyChunk = withHistory(baseChunk);
|
|
48
|
+
|
|
49
|
+
historyChunk.set(1);
|
|
50
|
+
historyChunk.set(2);
|
|
51
|
+
historyChunk.undo();
|
|
52
|
+
historyChunk.set(3); // This should create a new branch
|
|
53
|
+
|
|
54
|
+
expect(historyChunk.getHistory()).toEqual([0, 1, 3]);
|
|
55
|
+
expect(historyChunk.get()).toBe(3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should respect maxHistory limit', () => {
|
|
59
|
+
const baseChunk = chunk(0);
|
|
60
|
+
const historyChunk = withHistory(baseChunk, { maxHistory: 3 });
|
|
61
|
+
|
|
62
|
+
historyChunk.set(1);
|
|
63
|
+
historyChunk.set(2);
|
|
64
|
+
historyChunk.set(3);
|
|
65
|
+
historyChunk.set(4);
|
|
66
|
+
|
|
67
|
+
expect(historyChunk.getHistory()).toEqual([2, 3, 4]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should handle canUndo and canRedo correctly', () => {
|
|
71
|
+
const baseChunk = chunk(0);
|
|
72
|
+
const historyChunk = withHistory(baseChunk);
|
|
73
|
+
|
|
74
|
+
expect(historyChunk.canUndo()).toBe(false);
|
|
75
|
+
expect(historyChunk.canRedo()).toBe(false);
|
|
76
|
+
|
|
77
|
+
historyChunk.set(1);
|
|
78
|
+
expect(historyChunk.canUndo()).toBe(true);
|
|
79
|
+
expect(historyChunk.canRedo()).toBe(false);
|
|
80
|
+
|
|
81
|
+
historyChunk.undo();
|
|
82
|
+
expect(historyChunk.canUndo()).toBe(false);
|
|
83
|
+
expect(historyChunk.canRedo()).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should clear history properly', () => {
|
|
87
|
+
const baseChunk = chunk(0);
|
|
88
|
+
const historyChunk = withHistory(baseChunk);
|
|
89
|
+
|
|
90
|
+
historyChunk.set(1);
|
|
91
|
+
historyChunk.set(2);
|
|
92
|
+
|
|
93
|
+
historyChunk.clearHistory();
|
|
94
|
+
|
|
95
|
+
expect(historyChunk.getHistory()).toEqual([2]);
|
|
96
|
+
expect(historyChunk.canUndo()).toBe(false);
|
|
97
|
+
expect(historyChunk.canRedo()).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/tests/middleware.test.ts
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { chunk, select } from '../src/core/core';
|
|
2
|
+
|
|
3
|
+
describe('select', () => {
|
|
4
|
+
it('should create a selector that initially returns the correct value', () => {
|
|
5
|
+
const source = chunk({ name: 'John', age: 25 });
|
|
6
|
+
const nameSelector = select(source, user => user.name);
|
|
7
|
+
|
|
8
|
+
expect(nameSelector.get()).toBe('John');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should update when selected value changes', () => {
|
|
12
|
+
const source = chunk({ name: 'John', age: 25 });
|
|
13
|
+
const nameSelector = select(source, user => user.name);
|
|
14
|
+
|
|
15
|
+
source.set({ name: 'Jane', age: 25 });
|
|
16
|
+
expect(nameSelector.get()).toBe('Jane');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should not notify subscribers when non-selected values change', () => {
|
|
20
|
+
const source = chunk({ name: 'John', age: 25 });
|
|
21
|
+
const nameSelector = select(source, user => user.name);
|
|
22
|
+
|
|
23
|
+
const subscriber = jest.fn();
|
|
24
|
+
nameSelector.subscribe(subscriber);
|
|
25
|
+
|
|
26
|
+
// Reset the mock to ignore initial call
|
|
27
|
+
subscriber.mockReset();
|
|
28
|
+
|
|
29
|
+
// Update age only
|
|
30
|
+
source.set({ name: 'John', age: 26 });
|
|
31
|
+
|
|
32
|
+
expect(subscriber).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should notify subscribers when selected value changes', () => {
|
|
36
|
+
const source = chunk({ name: 'John', age: 25 });
|
|
37
|
+
const nameSelector = select(source, user => user.name);
|
|
38
|
+
|
|
39
|
+
const subscriber = jest.fn();
|
|
40
|
+
nameSelector.subscribe(subscriber);
|
|
41
|
+
|
|
42
|
+
// Reset the mock to ignore initial call
|
|
43
|
+
subscriber.mockReset();
|
|
44
|
+
|
|
45
|
+
source.set({ name: 'Jane', age: 25 });
|
|
46
|
+
|
|
47
|
+
expect(subscriber).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(subscriber).toHaveBeenCalledWith('Jane');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should prevent direct modifications to selector', () => {
|
|
52
|
+
const source = chunk({ name: 'John', age: 25 });
|
|
53
|
+
const nameSelector = select(source, user => user.name);
|
|
54
|
+
|
|
55
|
+
expect(() => {
|
|
56
|
+
nameSelector.set('Jane');
|
|
57
|
+
}).toThrow('Cannot set values directly on a selector');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should work with complex selectors', () => {
|
|
61
|
+
const source = chunk({ user: { profile: { name: 'John' } } });
|
|
62
|
+
const nameSelector = select(source, state => state.user.profile.name);
|
|
63
|
+
|
|
64
|
+
expect(nameSelector.get()).toBe('John');
|
|
65
|
+
|
|
66
|
+
source.set({ user: { profile: { name: 'Jane' } } });
|
|
67
|
+
expect(nameSelector.get()).toBe('Jane');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should handle array selectors', () => {
|
|
71
|
+
const source = chunk({ items: [1, 2, 3] });
|
|
72
|
+
const firstItemSelector = select(source, state => state.items[0]);
|
|
73
|
+
|
|
74
|
+
expect(firstItemSelector.get()).toBe(1);
|
|
75
|
+
|
|
76
|
+
source.set({ items: [4, 2, 3] });
|
|
77
|
+
expect(firstItemSelector.get()).toBe(4);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should work with computed values', () => {
|
|
81
|
+
const source = chunk({ numbers: [1, 2, 3, 4, 5] });
|
|
82
|
+
const sumSelector = select(source, state =>
|
|
83
|
+
state.numbers.reduce((sum, num) => sum + num, 0)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(sumSelector.get()).toBe(15);
|
|
87
|
+
|
|
88
|
+
source.set({ numbers: [1, 2, 3] });
|
|
89
|
+
expect(sumSelector.get()).toBe(6);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should properly clean up subscriptions on destroy', () => {
|
|
93
|
+
const source = chunk({ name: 'John', age: 25 });
|
|
94
|
+
const nameSelector = select(source, user => user.name);
|
|
95
|
+
|
|
96
|
+
const subscriber = jest.fn();
|
|
97
|
+
const unsubscribe = nameSelector.subscribe(subscriber);
|
|
98
|
+
|
|
99
|
+
// Reset mock to ignore initial call
|
|
100
|
+
subscriber.mockReset();
|
|
101
|
+
|
|
102
|
+
unsubscribe();
|
|
103
|
+
nameSelector.destroy();
|
|
104
|
+
source.set({ name: 'Jane', age: 25 });
|
|
105
|
+
|
|
106
|
+
expect(subscriber).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should work with multiple independent selectors', () => {
|
|
110
|
+
const source = chunk({ name: 'John', age: 25 });
|
|
111
|
+
const nameSelector = select(source, user => user.name);
|
|
112
|
+
const ageSelector = select(source, user => user.age);
|
|
113
|
+
|
|
114
|
+
const nameSubscriber = jest.fn();
|
|
115
|
+
const ageSubscriber = jest.fn();
|
|
116
|
+
|
|
117
|
+
nameSelector.subscribe(nameSubscriber);
|
|
118
|
+
ageSelector.subscribe(ageSubscriber);
|
|
119
|
+
|
|
120
|
+
// Reset mocks to ignore initial calls
|
|
121
|
+
nameSubscriber.mockReset();
|
|
122
|
+
ageSubscriber.mockReset();
|
|
123
|
+
|
|
124
|
+
source.set({ name: 'John', age: 26 });
|
|
125
|
+
expect(nameSubscriber).not.toHaveBeenCalled();
|
|
126
|
+
expect(ageSubscriber).toHaveBeenCalledWith(26);
|
|
127
|
+
|
|
128
|
+
source.set({ name: 'Jane', age: 26 });
|
|
129
|
+
expect(nameSubscriber).toHaveBeenCalledWith('Jane');
|
|
130
|
+
expect(ageSubscriber).toHaveBeenCalledTimes(1); // Still from previous update
|
|
131
|
+
});
|
|
132
|
+
});
|