@nerdalytics/beacon 1000.1.1 → 1000.2.1
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 +409 -155
- package/dist/src/index.d.ts +2 -26
- package/dist/src/index.js +9 -150
- package/dist/src/index.min.js +1 -0
- package/package.json +15 -11
- package/src/index.ts +13 -7
package/README.md
CHANGED
|
@@ -1,28 +1,50 @@
|
|
|
1
1
|
# Beacon <img align="right" src="https://raw.githubusercontent.com/nerdalytics/beacon/refs/heads/trunk/assets/beacon-logo.svg" width="128px" alt="A stylized lighthouse beacon with golden light against a dark blue background, representing the reactive state library"/>
|
|
2
2
|
|
|
3
|
+
> Lightweight reactive state management for Node.js backends
|
|
4
|
+
|
|
5
|
+
[](https://github.com/nerdalytics/beacon/blob/trunk/LICENSE)
|
|
6
|
+
[](https://www.npmjs.com/package/@nerdalytics/beacon)
|
|
7
|
+
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
[](https://typescriptlang.org/)
|
|
10
|
+
[](https://biomejs.dev/)
|
|
11
|
+
|
|
3
12
|
A lightweight reactive state library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.
|
|
4
13
|
|
|
5
|
-
|
|
14
|
+
<details>
|
|
15
|
+
<summary><Strong>Table of Contents</Strong></summary>
|
|
6
16
|
|
|
7
17
|
- [Features](#features)
|
|
8
|
-
- [
|
|
9
|
-
- [
|
|
10
|
-
- [API](#api)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
- [Quick Start](#quick-start)
|
|
19
|
+
- [Core Concepts](#core-concepts)
|
|
20
|
+
- [API Reference](#api-reference)
|
|
21
|
+
- [Core Primitives](#core-primitives)
|
|
22
|
+
- [state](#statetinitialvalue-t-equalityfn-a-t-b-t--boolean-statet)
|
|
23
|
+
- [derive](#derivetfn---t-readonlystatet)
|
|
24
|
+
- [effect](#effectfn---void---void)
|
|
25
|
+
- [batch](#batchtfn---t-t)
|
|
26
|
+
- [select](#selectt-rsource-readonlystatet-selectorfn-state-t--r-equalityfn-a-r-b-r--boolean-readonlystater)
|
|
27
|
+
- [lens](#lenst-ksource-statet-accessor-state-t--k-statek)
|
|
28
|
+
- [Access Control](#access-control)
|
|
29
|
+
- [readonlyState](#readonlystatetstate-statet-readonlystatet)
|
|
30
|
+
- [protectedState](#protectedstatetinitialvalue-t-equalityfn-a-t-b-t--boolean-readonlystatet-writeablestatet)
|
|
31
|
+
- [Advanced Features](#advanced-features)
|
|
32
|
+
- [Infinite Loop Protection](#infinite-loop-protection)
|
|
33
|
+
- [Automatic Cleanup](#automatic-cleanup)
|
|
34
|
+
- [Custom Equality Functions](#custom-equality-functions)
|
|
35
|
+
- [Design Philosophy](#design-philosophy)
|
|
36
|
+
- [Architecture](#architecture)
|
|
19
37
|
- [Development](#development)
|
|
20
|
-
|
|
21
|
-
- [Key Differences vs TC39 Proposal](#key-differences-between-my-library-and-the-tc39-proposal)
|
|
22
|
-
- [Implementation Details](#implementation-details)
|
|
38
|
+
- [Key Differences vs TC39 Proposal](#key-differences-vs-tc39-proposal)
|
|
23
39
|
- [FAQ](#faq)
|
|
40
|
+
- [Why "Beacon" Instead of "Signal"?](#why-beacon-instead-of-signal)
|
|
41
|
+
- [How does Beacon handle memory management?](#how-does-beacon-handle-memory-management)
|
|
42
|
+
- [Can I use Beacon with Express or other frameworks?](#can-i-use-beacon-with-express-or-other-frameworks)
|
|
43
|
+
- [Can Beacon be used in browser applications?](#can-beacon-be-used-in-browser-applications)
|
|
24
44
|
- [License](#license)
|
|
25
45
|
|
|
46
|
+
</details>
|
|
47
|
+
|
|
26
48
|
## Features
|
|
27
49
|
|
|
28
50
|
- 📶 **Reactive state** - Create reactive values that automatically track dependencies
|
|
@@ -35,67 +57,209 @@ A lightweight reactive state library for Node.js backends. Enables reactive stat
|
|
|
35
57
|
- ♻️ **Cycle handling** - Safely manages cyclic dependencies without crashing
|
|
36
58
|
- 🚨 **Infinite loop detection** - Automatically detects and prevents infinite update loops
|
|
37
59
|
- 🛠️ **TypeScript-first** - Full TypeScript support with generics
|
|
38
|
-
- 🪶 **Lightweight** - Zero dependencies
|
|
60
|
+
- 🪶 **Lightweight** - Zero dependencies
|
|
39
61
|
- ✅ **Node.js compatibility** - Works with Node.js LTS v20+ and v22+
|
|
40
62
|
|
|
41
|
-
##
|
|
63
|
+
## Quick Start
|
|
42
64
|
|
|
43
|
-
```
|
|
44
|
-
npm install @nerdalytics/beacon
|
|
65
|
+
```other
|
|
66
|
+
npm install @nerdalytics/beacon --save-exact
|
|
45
67
|
```
|
|
46
68
|
|
|
47
|
-
## Usage
|
|
48
|
-
|
|
49
69
|
```typescript
|
|
50
|
-
import { state, derive, effect
|
|
70
|
+
import { state, derive, effect } from '@nerdalytics/beacon';
|
|
51
71
|
|
|
52
72
|
// Create reactive state
|
|
53
73
|
const count = state(0);
|
|
54
|
-
const doubled = derive(() => count() * 2);
|
|
55
74
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
console.log(doubled()); // => 0
|
|
75
|
+
// Create a derived value
|
|
76
|
+
const doubled = derive(() => count() * 2);
|
|
59
77
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
console.log(`Count is ${count()}, doubled is ${doubled()}`);
|
|
78
|
+
// Set up an effect
|
|
79
|
+
effect(() => {
|
|
80
|
+
console.log(`Count: ${count()}, Doubled: ${doubled()}`);
|
|
64
81
|
});
|
|
65
|
-
// => "Count
|
|
82
|
+
// => "Count: 0, Doubled: 0"
|
|
66
83
|
|
|
67
|
-
// Update
|
|
84
|
+
// Update the state - effect runs automatically
|
|
68
85
|
count.set(5);
|
|
69
|
-
// => "Count
|
|
86
|
+
// => "Count: 5, Doubled: 10"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Core Concepts
|
|
90
|
+
|
|
91
|
+
Beacon is built around three core primitives:
|
|
92
|
+
|
|
93
|
+
1. **States**: Mutable, reactive values
|
|
94
|
+
2. **Derived States**: Read-only computed values that update automatically
|
|
95
|
+
3. **Effects**: Side effects that run automatically when dependencies change
|
|
96
|
+
|
|
97
|
+
The library handles all the dependency tracking and updates automatically, so you can focus on your business logic.
|
|
98
|
+
|
|
99
|
+
## API Reference
|
|
100
|
+
|
|
101
|
+
### Version Compatibility
|
|
102
|
+
|
|
103
|
+
The table below tracks when features were introduced and when function signatures were changed.
|
|
104
|
+
|
|
105
|
+
| API | Introduced | Last Updated | Notes |
|
|
106
|
+
|-----|------------|--------------|-------|
|
|
107
|
+
| `state` | v1.0.0 | v1000.2.0 | Added `equalityFn` parameter |
|
|
108
|
+
| `derive` | v1.0.0 | v1000.0.0 | Renamed `derived` → `derive` |
|
|
109
|
+
| `effect` | v1.0.0 | - | - |
|
|
110
|
+
| `batch` | v1.0.0 | - | - |
|
|
111
|
+
| `select` | v1000.0.0 | - | - |
|
|
112
|
+
| `lens` | v1000.1.0 | - | - |
|
|
113
|
+
| `readonlyState` | v1000.0.0 | - | - |
|
|
114
|
+
| `protectedState` | v1000.0.0 | v1000.2.0 | Added `equalityFn` parameter |
|
|
115
|
+
|
|
116
|
+
### Core Primitives
|
|
117
|
+
|
|
118
|
+
#### `state<T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean): State<T>`
|
|
119
|
+
> *Since v1.0.0*
|
|
120
|
+
|
|
121
|
+
The foundation of Beacon's reactivity system. Create with `state()` and use like a function.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { state } from '@nerdalytics/beacon';
|
|
125
|
+
|
|
126
|
+
const counter = state(0);
|
|
127
|
+
|
|
128
|
+
// Read current value
|
|
129
|
+
console.log(counter()); // => 0
|
|
130
|
+
|
|
131
|
+
// Update value
|
|
132
|
+
counter.set(5);
|
|
133
|
+
console.log(counter()); // => 5
|
|
70
134
|
|
|
71
135
|
// Update with a function
|
|
72
|
-
|
|
73
|
-
// =>
|
|
136
|
+
counter.update(n => n + 1);
|
|
137
|
+
console.log(counter()); // => 6
|
|
138
|
+
|
|
139
|
+
// With custom equality function
|
|
140
|
+
const deepCounter = state({ value: 0 }, (a, b) => {
|
|
141
|
+
// Deep equality check
|
|
142
|
+
return a.value === b.value;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// This won't trigger effects because values are deeply equal
|
|
146
|
+
deepCounter.set({ value: 0 });
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
#### `derive<T>(fn: () => T): ReadOnlyState<T>`
|
|
150
|
+
> *Since v1.0.0*
|
|
151
|
+
|
|
152
|
+
Calculate values based on other states. Updates automatically when dependencies change.
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { state, derive } from '@nerdalytics/beacon';
|
|
156
|
+
|
|
157
|
+
const firstName = state('John');
|
|
158
|
+
const lastName = state('Doe');
|
|
159
|
+
|
|
160
|
+
const fullName = derive(() => `${firstName()} ${lastName()}`);
|
|
161
|
+
|
|
162
|
+
console.log(fullName()); // => "John Doe"
|
|
163
|
+
|
|
164
|
+
firstName.set('Jane');
|
|
165
|
+
console.log(fullName()); // => "Jane Doe"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
#### `effect(fn: () => void): () => void`
|
|
169
|
+
> *Since v1.0.0*
|
|
170
|
+
|
|
171
|
+
Run side effects when reactive values change.
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { state, effect } from '@nerdalytics/beacon';
|
|
175
|
+
|
|
176
|
+
const user = state({ name: 'Alice', loggedIn: false });
|
|
177
|
+
|
|
178
|
+
const cleanup = effect(() => {
|
|
179
|
+
console.log(`User ${user().name} is ${user().loggedIn ? 'online' : 'offline'}`);
|
|
180
|
+
});
|
|
181
|
+
// => "User Alice is offline" (effect runs immediately when created)
|
|
182
|
+
|
|
183
|
+
user.update(u => ({ ...u, loggedIn: true }));
|
|
184
|
+
// => "User Alice is online"
|
|
185
|
+
|
|
186
|
+
// Stop the effect and clean up all subscriptions
|
|
187
|
+
cleanup();
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### `batch<T>(fn: () => T): T`
|
|
191
|
+
> *Since v1.0.0*
|
|
192
|
+
|
|
193
|
+
Group multiple updates to trigger effects only once.
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { state, effect, batch } from "@nerdalytics/beacon";
|
|
197
|
+
|
|
198
|
+
const count = state(0);
|
|
199
|
+
|
|
200
|
+
effect(() => {
|
|
201
|
+
console.log(`Count is ${count()}`);
|
|
202
|
+
});
|
|
203
|
+
// => "Count is 0" (effect runs immediately)
|
|
204
|
+
|
|
205
|
+
// Without batching, effects run after each update
|
|
206
|
+
count.set(1);
|
|
207
|
+
// => "Count is 1"
|
|
208
|
+
count.set(2);
|
|
209
|
+
// => "Count is 2"
|
|
74
210
|
|
|
75
211
|
// Batch updates (only triggers effects once at the end)
|
|
76
212
|
batch(() => {
|
|
77
213
|
count.set(10);
|
|
78
214
|
count.set(20);
|
|
215
|
+
count.set(30);
|
|
79
216
|
});
|
|
80
|
-
// => "Count is
|
|
217
|
+
// => "Count is 30" (only once)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### `select<T, R>(source: ReadOnlyState<T>, selectorFn: (state: T) => R, equalityFn?: (a: R, b: R) => boolean): ReadOnlyState<R>`
|
|
221
|
+
> *Since v1000.0.0*
|
|
81
222
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
223
|
+
Subscribe to specific parts of a state object.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { state, select, effect } from '@nerdalytics/beacon';
|
|
227
|
+
|
|
228
|
+
const user = state({
|
|
229
|
+
profile: { name: 'Alice' },
|
|
230
|
+
preferences: { theme: 'dark' }
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Only triggers when name changes
|
|
234
|
+
const nameState = select(user, u => u.profile.name);
|
|
85
235
|
|
|
86
236
|
effect(() => {
|
|
87
|
-
console.log(`Name
|
|
237
|
+
console.log(`Name: ${nameState()}`);
|
|
88
238
|
});
|
|
89
|
-
// => "Name
|
|
239
|
+
// => "Name: Alice"
|
|
240
|
+
|
|
241
|
+
// This triggers the effect
|
|
242
|
+
user.update(u => ({
|
|
243
|
+
...u,
|
|
244
|
+
profile: { ...u.profile, name: 'Bob' }
|
|
245
|
+
}));
|
|
246
|
+
// => "Name: Bob"
|
|
247
|
+
|
|
248
|
+
// This doesn't trigger the effect (theme changed, not name)
|
|
249
|
+
user.update(u => ({
|
|
250
|
+
...u,
|
|
251
|
+
preferences: { ...u.preferences, theme: 'light' }
|
|
252
|
+
}));
|
|
253
|
+
```
|
|
90
254
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// => "Name changed: Bob"
|
|
255
|
+
#### `lens<T, K>(source: State<T>, accessor: (state: T) => K): State<K>`
|
|
256
|
+
> *Since v1000.1.0*
|
|
94
257
|
|
|
95
|
-
|
|
96
|
-
|
|
258
|
+
Two-way binding to deeply nested properties.
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
import { state, lens, effect } from "@nerdalytics/beacon";
|
|
97
262
|
|
|
98
|
-
// Using lens for two-way binding with nested properties
|
|
99
263
|
const nested = state({
|
|
100
264
|
user: {
|
|
101
265
|
profile: {
|
|
@@ -118,165 +282,255 @@ themeLens.set("light");
|
|
|
118
282
|
console.log(themeLens()); // => "light"
|
|
119
283
|
console.log(nested().user.profile.settings.theme); // => "light"
|
|
120
284
|
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
285
|
+
// The entire object is updated with proper referential integrity
|
|
286
|
+
// This makes it easy to detect changes throughout the object tree
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Access Control
|
|
290
|
+
|
|
291
|
+
Control who can read vs. write to your state.
|
|
292
|
+
|
|
293
|
+
#### `readonlyState<T>(state: State<T>): ReadOnlyState<T>`
|
|
294
|
+
> *Since v1000.0.0*
|
|
295
|
+
|
|
296
|
+
Creates a read-only view of a state, hiding mutation methods. Useful when you want to expose state to other parts of your application without allowing direct mutations.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
import { state, readonlyState } from "@nerdalytics/beacon";
|
|
124
300
|
|
|
125
|
-
// Using readonlyState to create a read-only view
|
|
126
301
|
const counter = state(0);
|
|
127
302
|
const readonlyCounter = readonlyState(counter);
|
|
128
|
-
// readonlyCounter() works, but readonlyCounter.set() is not available
|
|
129
303
|
|
|
130
|
-
//
|
|
304
|
+
// Reading works
|
|
305
|
+
console.log(readonlyCounter()); // => 0
|
|
306
|
+
|
|
307
|
+
// Updating the original state reflects in the readonly view
|
|
308
|
+
counter.set(5);
|
|
309
|
+
console.log(readonlyCounter()); // => 5
|
|
310
|
+
|
|
311
|
+
// This would cause a TypeScript error since readonlyCounter has no set method
|
|
312
|
+
// readonlyCounter.set(10); // Error: Property 'set' does not exist
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
#### `protectedState<T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean): [ReadOnlyState<T>, WriteableState<T>]`
|
|
316
|
+
> *Since v1000.0.0*
|
|
317
|
+
|
|
318
|
+
Creates a state with separated read and write capabilities, returning a tuple of reader and writer. This pattern allows you to expose only the reading capability to consuming code while keeping the writing capability private.
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
import { protectedState } from "@nerdalytics/beacon";
|
|
322
|
+
|
|
323
|
+
// Create a state with separated read and write capabilities
|
|
131
324
|
const [getUser, setUser] = protectedState({ name: 'Alice' });
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
console.error('Prevented infinite loop:', error.message);
|
|
325
|
+
|
|
326
|
+
// Read the state
|
|
327
|
+
console.log(getUser()); // => { name: 'Alice' }
|
|
328
|
+
|
|
329
|
+
// Update the state
|
|
330
|
+
setUser.set({ name: 'Bob' });
|
|
331
|
+
console.log(getUser()); // => { name: 'Bob' }
|
|
332
|
+
|
|
333
|
+
// This is useful for exposing only read access to outside consumers
|
|
334
|
+
function createProtectedCounter() {
|
|
335
|
+
const [getCount, setCount] = protectedState(0);
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
value: getCount,
|
|
339
|
+
increment: () => setCount.update(n => n + 1),
|
|
340
|
+
decrement: () => setCount.update(n => n - 1)
|
|
341
|
+
};
|
|
150
342
|
}
|
|
343
|
+
|
|
344
|
+
const counter = createProtectedCounter();
|
|
345
|
+
console.log(counter.value()); // => 0
|
|
346
|
+
counter.increment();
|
|
347
|
+
console.log(counter.value()); // => 1
|
|
151
348
|
```
|
|
152
349
|
|
|
153
|
-
##
|
|
350
|
+
## Advanced Features
|
|
154
351
|
|
|
155
|
-
|
|
352
|
+
Beacon includes several advanced capabilities that help you build robust applications.
|
|
156
353
|
|
|
157
|
-
|
|
354
|
+
### Infinite Loop Protection
|
|
158
355
|
|
|
159
|
-
|
|
356
|
+
Beacon prevents common mistakes that could cause infinite loops:
|
|
160
357
|
|
|
161
|
-
|
|
358
|
+
```typescript
|
|
359
|
+
import { state, effect } from '@nerdalytics/beacon';
|
|
162
360
|
|
|
163
|
-
|
|
361
|
+
const counter = state(0);
|
|
164
362
|
|
|
165
|
-
|
|
363
|
+
// This would throw an error
|
|
364
|
+
effect(() => {
|
|
365
|
+
const value = counter();
|
|
366
|
+
counter.set(value + 1); // Error: Infinite loop detected!
|
|
367
|
+
});
|
|
166
368
|
|
|
167
|
-
|
|
369
|
+
// Instead, use proper patterns like:
|
|
370
|
+
const increment = () => counter.update(n => n + 1);
|
|
371
|
+
```
|
|
168
372
|
|
|
169
|
-
|
|
373
|
+
### Automatic Cleanup
|
|
170
374
|
|
|
171
|
-
|
|
375
|
+
All subscriptions are automatically cleaned up when effects are unsubscribed:
|
|
172
376
|
|
|
173
|
-
|
|
377
|
+
```typescript
|
|
378
|
+
import { state, effect } from '@nerdalytics/beacon';
|
|
379
|
+
|
|
380
|
+
const data = state({ loading: true, items: [] });
|
|
381
|
+
|
|
382
|
+
// Effect with nested effect
|
|
383
|
+
const cleanup = effect(() => {
|
|
384
|
+
if (data().loading) {
|
|
385
|
+
console.log('Loading...');
|
|
386
|
+
} else {
|
|
387
|
+
// This nested effect is automatically cleaned up when the parent is
|
|
388
|
+
effect(() => {
|
|
389
|
+
console.log(`${data().items.length} items loaded`);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
});
|
|
174
393
|
|
|
175
|
-
|
|
394
|
+
// Unsubscribe cleans up everything, including nested effects
|
|
395
|
+
cleanup();
|
|
396
|
+
```
|
|
176
397
|
|
|
177
|
-
|
|
398
|
+
### Custom Equality Functions
|
|
178
399
|
|
|
179
|
-
|
|
400
|
+
Control when subscribers are notified with custom equality checks. You can provide custom equality functions to `state`, `select`, and `protectedState`:
|
|
180
401
|
|
|
181
|
-
|
|
402
|
+
```typescript
|
|
403
|
+
import { state, select, effect, protectedState } from '@nerdalytics/beacon';
|
|
182
404
|
|
|
183
|
-
|
|
405
|
+
// Custom equality function for state
|
|
406
|
+
// Only trigger updates when references are different (useful for logging)
|
|
407
|
+
const logMessages = state([], (a, b) => a === b); // Reference equality
|
|
184
408
|
|
|
185
|
-
|
|
409
|
+
// Add logs - each call triggers effects even with identical content
|
|
410
|
+
logMessages.set(['System started']); // Triggers effects
|
|
411
|
+
logMessages.set(['System started']); // Triggers effects again
|
|
186
412
|
|
|
187
|
-
|
|
413
|
+
// Protected state with custom equality function
|
|
414
|
+
const [getConfig, setConfig] = protectedState({ theme: 'dark' }, (a, b) => {
|
|
415
|
+
// Only consider configs equal if all properties match
|
|
416
|
+
return a.theme === b.theme;
|
|
417
|
+
});
|
|
188
418
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
npm install
|
|
419
|
+
// Custom equality with select
|
|
420
|
+
const list = state([1, 2, 3]);
|
|
192
421
|
|
|
193
|
-
|
|
194
|
-
|
|
422
|
+
// Only notify when array length changes, not on content changes
|
|
423
|
+
const listLengthState = select(
|
|
424
|
+
list,
|
|
425
|
+
arr => arr.length,
|
|
426
|
+
(a, b) => a === b
|
|
427
|
+
);
|
|
195
428
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
# Run specific test suites
|
|
200
|
-
# Core functionality
|
|
201
|
-
npm run test:unit:state
|
|
202
|
-
npm run test:unit:effect
|
|
203
|
-
npm run test:unit:derive
|
|
204
|
-
npm run test:unit:batch
|
|
205
|
-
npm run test:unit:select
|
|
206
|
-
npm run test:unit:lens
|
|
207
|
-
npm run test:unit:readonly
|
|
208
|
-
npm run test:unit:protected
|
|
209
|
-
|
|
210
|
-
# Advanced patterns
|
|
211
|
-
npm run test:unit:cleanup # Tests for effect cleanup behavior
|
|
212
|
-
npm run test:unit:cyclic-dependency # Tests for cyclic dependency handling
|
|
213
|
-
npm run test:unit:deep-chain # Tests for deep chain handling
|
|
214
|
-
npm run test:unit:infinite-loop # Tests for infinite loop detection
|
|
215
|
-
|
|
216
|
-
# Benchmarking
|
|
217
|
-
npm run benchmark # Tests for infinite loop detection
|
|
218
|
-
|
|
219
|
-
# Format code
|
|
220
|
-
npm run format
|
|
221
|
-
npm run lint
|
|
222
|
-
npm run check # Runs Bioms lint + format
|
|
429
|
+
effect(() => {
|
|
430
|
+
console.log(`List has ${listLengthState()} items`);
|
|
431
|
+
});
|
|
223
432
|
```
|
|
224
433
|
|
|
225
|
-
|
|
434
|
+
## Design Philosophy
|
|
435
|
+
|
|
436
|
+
Beacon follows these key principles:
|
|
437
|
+
|
|
438
|
+
1. **Simplicity**: Minimal API surface with powerful primitives
|
|
439
|
+
2. **Fine-grained reactivity**: Track dependencies at exactly the right level
|
|
440
|
+
3. **Predictability**: State changes flow predictably through the system
|
|
441
|
+
4. **Performance**: Optimize for server workloads and memory efficiency
|
|
442
|
+
5. **Type safety**: Full TypeScript support with generics
|
|
443
|
+
|
|
444
|
+
## Architecture
|
|
445
|
+
|
|
446
|
+
Beacon is built around a centralized reactivity system with fine-grained dependency tracking. Here's how it works:
|
|
447
|
+
|
|
448
|
+
- **Automatic Dependency Collection**: When a state is read inside an effect, Beacon automatically records this dependency
|
|
449
|
+
- **WeakMap-based Tracking**: Uses WeakMaps for automatic garbage collection
|
|
450
|
+
- **Topological Updates**: Updates flow through the dependency graph in the correct order
|
|
451
|
+
- **Memory-Efficient**: Designed for long-running Node.js processes
|
|
452
|
+
|
|
453
|
+
### Dependency Tracking
|
|
454
|
+
|
|
455
|
+
When a state is read inside an effect, Beacon automatically records this dependency relationship and sets up a subscription.
|
|
456
|
+
|
|
457
|
+
### Infinite Loop Prevention
|
|
226
458
|
|
|
227
|
-
Beacon
|
|
459
|
+
Beacon actively detects when an effect tries to update a state it depends on, preventing common infinite update cycles:
|
|
228
460
|
|
|
229
|
-
|
|
461
|
+
```typescript
|
|
462
|
+
// This would throw: "Infinite loop detected"
|
|
463
|
+
effect(() => {
|
|
464
|
+
const value = counter();
|
|
465
|
+
counter.set(value + 1); // Error! Updating a state the effect depends on
|
|
466
|
+
});
|
|
467
|
+
```
|
|
230
468
|
|
|
231
|
-
|
|
232
|
-
|--------|---------------------|---------------|
|
|
233
|
-
| **API Style** | Functional approach (`state()`, `derive()`) | Class-based design (`Signal.State`, `Signal.Computed`) |
|
|
234
|
-
| **Reading/Writing Pattern** | Function call for reading (`count()`), methods for writing (`count.set(5)`) | Method-based access (`get()`/`set()`) |
|
|
235
|
-
| **Framework Support** | High-level abstractions like `effect()` and `batch()` | Lower-level primitives (`Signal.subtle.Watcher`) that frameworks build upon |
|
|
236
|
-
| **Advanced Features** | Focused on core reactivity | Includes introspection capabilities, watched/unwatched callbacks, and Signal.subtle namespace |
|
|
237
|
-
| **Scope and Purpose** | Practical Node.js use cases with minimal API surface | Standardization with robust interoperability between frameworks |
|
|
469
|
+
### Cyclic Dependencies
|
|
238
470
|
|
|
239
|
-
|
|
471
|
+
Beacon employs two complementary strategies for handling cyclical updates:
|
|
240
472
|
|
|
241
|
-
|
|
473
|
+
1. **Active Detection**: The system tracks which states an effect reads from and writes to. If an effect attempts to directly update a state it depends on, Beacon throws a clear error.
|
|
474
|
+
2. **Safe Cycles**: For indirect cycles and safe update patterns, Beacon uses a queue-based update system that won't crash even with cyclical dependencies. When states form a cycle where values eventually stabilize, the system handles these updates efficiently without stack overflows.
|
|
242
475
|
|
|
243
|
-
|
|
476
|
+
## Development
|
|
244
477
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
478
|
+
```other
|
|
479
|
+
# Install dependencies
|
|
480
|
+
npm install
|
|
481
|
+
|
|
482
|
+
# Run tests
|
|
483
|
+
npm test
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
## Key Differences vs [TC39 Proposal][1]
|
|
487
|
+
|
|
488
|
+
| **Aspect** | **@nerdalytics/beacon** | **TC39 Proposal** |
|
|
489
|
+
| --------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
490
|
+
| **API Style** | Functional approach (`state()`, `derive()`) | Class-based design (`Signal.State`, `Signal.Computed`) |
|
|
491
|
+
| **Reading/Writing Pattern** | Function call for reading (`count()`), methods for writing (`count.set(5)`) | Method-based access (`get()`/`set()`) |
|
|
492
|
+
| **Framework Support** | High-level abstractions like `effect()` and `batch()` | Lower-level primitives (`Signal.subtle.Watcher`) that frameworks build upon |
|
|
493
|
+
| **Advanced Features** | Focused on core reactivity | Includes introspection capabilities, watched/unwatched callbacks, and Signal.subtle namespace |
|
|
494
|
+
| **Scope and Purpose** | Practical Node.js use cases with minimal API surface | Standardization with robust interoperability between frameworks |
|
|
251
495
|
|
|
252
496
|
## FAQ
|
|
253
497
|
|
|
254
|
-
|
|
498
|
+
#### Why "Beacon" Instead of "Signal"?
|
|
255
499
|
|
|
256
|
-
|
|
257
|
-
I chose "Beacon" because it clearly represents how the library broadcasts notifications when state changes—just like a lighthouse guides ships. While my library draws inspiration from Preact Signals, Angular Signals, and aspects of Svelte, I wanted to create something lighter and specifically designed for Node.js backends. Using "Beacon" instead of the term "Signal" helps avoid confusion with the TC39 proposal and similar libraries while still accurately describing the core functionality.
|
|
500
|
+
Beacon represents how the library broadcasts notifications when state changes—just like a lighthouse guides ships. The name avoids confusion with the TC39 proposal and similar libraries while accurately describing the core functionality.
|
|
258
501
|
|
|
259
|
-
|
|
502
|
+
#### How does Beacon handle memory management?
|
|
260
503
|
|
|
261
|
-
|
|
504
|
+
Beacon uses WeakMaps for dependency tracking, ensuring that unused states and effects can be garbage collected. When you unsubscribe an effect, all its internal subscriptions are automatically cleaned up.
|
|
262
505
|
|
|
263
|
-
|
|
264
|
-
Beacon employs two complementary strategies for handling cyclical updates:
|
|
506
|
+
#### Can I use Beacon with Express or other frameworks?
|
|
265
507
|
|
|
266
|
-
|
|
508
|
+
Yes! Beacon works well as a state management solution in any Node.js application:
|
|
267
509
|
|
|
268
|
-
|
|
510
|
+
```typescript
|
|
511
|
+
import express from 'express';
|
|
512
|
+
import { state, effect } from '@nerdalytics/beacon';
|
|
269
513
|
|
|
270
|
-
|
|
514
|
+
const app = express();
|
|
515
|
+
const stats = state({ requests: 0, errors: 0 });
|
|
271
516
|
|
|
272
|
-
|
|
517
|
+
// Update stats on each request
|
|
518
|
+
app.use((req, res, next) => {
|
|
519
|
+
stats.update(s => ({ ...s, requests: s.requests + 1 }));
|
|
520
|
+
next();
|
|
521
|
+
});
|
|
273
522
|
|
|
274
|
-
|
|
523
|
+
// Log stats every minute
|
|
524
|
+
effect(() => {
|
|
525
|
+
console.log(`Stats: ${stats().requests} requests, ${stats().errors} errors`);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
app.listen(3000);
|
|
529
|
+
```
|
|
275
530
|
|
|
276
|
-
|
|
277
|
-
Beacon is designed with performance in mind for server-side Node.js environments. It achieves millions of operations per second for core operations like reading and writing states.
|
|
531
|
+
#### Can Beacon be used in browser applications?
|
|
278
532
|
|
|
279
|
-
|
|
533
|
+
While Beacon is optimized for Node.js server-side applications, its core principles would work in browser environments. However, the library is specifically designed for backend use cases and hasn't been optimized for browser bundle sizes or DOM integration patterns.
|
|
280
534
|
|
|
281
535
|
## License
|
|
282
536
|
|
package/dist/src/index.d.ts
CHANGED
|
@@ -8,36 +8,12 @@ declare const STATE_ID: unique symbol;
|
|
|
8
8
|
export type State<T> = ReadOnlyState<T> & WriteableState<T> & {
|
|
9
9
|
[STATE_ID]?: symbol;
|
|
10
10
|
};
|
|
11
|
-
|
|
12
|
-
* Creates a reactive state container with the provided initial value.
|
|
13
|
-
*/
|
|
14
|
-
export declare const state: <T>(initialValue: T) => State<T>;
|
|
15
|
-
/**
|
|
16
|
-
* Registers a function to run whenever its reactive dependencies change.
|
|
17
|
-
*/
|
|
11
|
+
export declare const state: <T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean) => State<T>;
|
|
18
12
|
export declare const effect: (fn: () => void) => Unsubscribe;
|
|
19
|
-
/**
|
|
20
|
-
* Groups multiple state updates to trigger effects only once at the end.
|
|
21
|
-
*/
|
|
22
13
|
export declare const batch: <T>(fn: () => T) => T;
|
|
23
|
-
/**
|
|
24
|
-
* Creates a read-only computed value that updates when its dependencies change.
|
|
25
|
-
*/
|
|
26
14
|
export declare const derive: <T>(computeFn: () => T) => ReadOnlyState<T>;
|
|
27
|
-
/**
|
|
28
|
-
* Creates an efficient subscription to a subset of a state value.
|
|
29
|
-
*/
|
|
30
15
|
export declare const select: <T, R>(source: ReadOnlyState<T>, selectorFn: (state: T) => R, equalityFn?: (a: R, b: R) => boolean) => ReadOnlyState<R>;
|
|
31
|
-
/**
|
|
32
|
-
* Creates a read-only view of a state, hiding mutation methods.
|
|
33
|
-
*/
|
|
34
16
|
export declare const readonlyState: <T>(state: State<T>) => ReadOnlyState<T>;
|
|
35
|
-
|
|
36
|
-
* Creates a state with access control, returning a tuple of reader and writer.
|
|
37
|
-
*/
|
|
38
|
-
export declare const protectedState: <T>(initialValue: T) => [ReadOnlyState<T>, WriteableState<T>];
|
|
39
|
-
/**
|
|
40
|
-
* Creates a lens for direct updates to nested properties of a state.
|
|
41
|
-
*/
|
|
17
|
+
export declare const protectedState: <T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean) => [ReadOnlyState<T>, WriteableState<T>];
|
|
42
18
|
export declare const lens: <T, K>(source: State<T>, accessor: (state: T) => K) => State<K>;
|
|
43
19
|
export {};
|
package/dist/src/index.js
CHANGED
|
@@ -1,34 +1,12 @@
|
|
|
1
|
-
// Special symbol used for internal tracking
|
|
2
1
|
const STATE_ID = Symbol();
|
|
3
|
-
|
|
4
|
-
* Creates a reactive state container with the provided initial value.
|
|
5
|
-
*/
|
|
6
|
-
export const state = (initialValue) => StateImpl.createState(initialValue);
|
|
7
|
-
/**
|
|
8
|
-
* Registers a function to run whenever its reactive dependencies change.
|
|
9
|
-
*/
|
|
2
|
+
export const state = (initialValue, equalityFn = Object.is) => StateImpl.createState(initialValue, equalityFn);
|
|
10
3
|
export const effect = (fn) => StateImpl.createEffect(fn);
|
|
11
|
-
/**
|
|
12
|
-
* Groups multiple state updates to trigger effects only once at the end.
|
|
13
|
-
*/
|
|
14
4
|
export const batch = (fn) => StateImpl.executeBatch(fn);
|
|
15
|
-
/**
|
|
16
|
-
* Creates a read-only computed value that updates when its dependencies change.
|
|
17
|
-
*/
|
|
18
5
|
export const derive = (computeFn) => StateImpl.createDerive(computeFn);
|
|
19
|
-
/**
|
|
20
|
-
* Creates an efficient subscription to a subset of a state value.
|
|
21
|
-
*/
|
|
22
6
|
export const select = (source, selectorFn, equalityFn = Object.is) => StateImpl.createSelect(source, selectorFn, equalityFn);
|
|
23
|
-
/**
|
|
24
|
-
* Creates a read-only view of a state, hiding mutation methods.
|
|
25
|
-
*/
|
|
26
7
|
export const readonlyState = (state) => () => state();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*/
|
|
30
|
-
export const protectedState = (initialValue) => {
|
|
31
|
-
const fullState = state(initialValue);
|
|
8
|
+
export const protectedState = (initialValue, equalityFn = Object.is) => {
|
|
9
|
+
const fullState = state(initialValue, equalityFn);
|
|
32
10
|
return [
|
|
33
11
|
() => readonlyState(fullState)(),
|
|
34
12
|
{
|
|
@@ -37,61 +15,44 @@ export const protectedState = (initialValue) => {
|
|
|
37
15
|
},
|
|
38
16
|
];
|
|
39
17
|
};
|
|
40
|
-
/**
|
|
41
|
-
* Creates a lens for direct updates to nested properties of a state.
|
|
42
|
-
*/
|
|
43
18
|
export const lens = (source, accessor) => StateImpl.createLens(source, accessor);
|
|
44
19
|
class StateImpl {
|
|
45
|
-
// Static fields track global reactivity state - this centralized approach allows
|
|
46
|
-
// for coordinated updates while maintaining individual state isolation
|
|
47
20
|
static currentSubscriber = null;
|
|
48
21
|
static pendingSubscribers = new Set();
|
|
49
22
|
static isNotifying = false;
|
|
50
23
|
static batchDepth = 0;
|
|
51
24
|
static deferredEffectCreations = [];
|
|
52
25
|
static activeSubscribers = new Set();
|
|
53
|
-
// WeakMaps enable automatic garbage collection when subscribers are no
|
|
54
|
-
// longer referenced, preventing memory leaks in long-running applications
|
|
55
26
|
static stateTracking = new WeakMap();
|
|
56
27
|
static subscriberDependencies = new WeakMap();
|
|
57
28
|
static parentSubscriber = new WeakMap();
|
|
58
29
|
static childSubscribers = new WeakMap();
|
|
59
|
-
// Instance state - each state has unique subscribers and ID
|
|
60
30
|
value;
|
|
61
31
|
subscribers = new Set();
|
|
62
32
|
stateId = Symbol();
|
|
63
|
-
|
|
33
|
+
equalityFn;
|
|
34
|
+
constructor(initialValue, equalityFn = Object.is) {
|
|
64
35
|
this.value = initialValue;
|
|
36
|
+
this.equalityFn = equalityFn;
|
|
65
37
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
* Implementation of the public 'state' function.
|
|
69
|
-
*/
|
|
70
|
-
static createState = (initialValue) => {
|
|
71
|
-
const instance = new StateImpl(initialValue);
|
|
38
|
+
static createState = (initialValue, equalityFn = Object.is) => {
|
|
39
|
+
const instance = new StateImpl(initialValue, equalityFn);
|
|
72
40
|
const get = () => instance.get();
|
|
73
41
|
get.set = (value) => instance.set(value);
|
|
74
42
|
get.update = (fn) => instance.update(fn);
|
|
75
43
|
get[STATE_ID] = instance.stateId;
|
|
76
44
|
return get;
|
|
77
45
|
};
|
|
78
|
-
// Auto-tracks dependencies when called within effects, creating a fine-grained
|
|
79
|
-
// reactivity graph that only updates affected components
|
|
80
46
|
get = () => {
|
|
81
47
|
const currentEffect = StateImpl.currentSubscriber;
|
|
82
48
|
if (currentEffect) {
|
|
83
|
-
// Add this effect to subscribers for future notification
|
|
84
49
|
this.subscribers.add(currentEffect);
|
|
85
|
-
// Maintain bidirectional dependency tracking to enable precise cleanup
|
|
86
|
-
// when effects are unsubscribed, preventing memory leaks
|
|
87
50
|
let dependencies = StateImpl.subscriberDependencies.get(currentEffect);
|
|
88
51
|
if (!dependencies) {
|
|
89
52
|
dependencies = new Set();
|
|
90
53
|
StateImpl.subscriberDependencies.set(currentEffect, dependencies);
|
|
91
54
|
}
|
|
92
55
|
dependencies.add(this.subscribers);
|
|
93
|
-
// Track read states to detect direct cyclical dependencies that
|
|
94
|
-
// could cause infinite loops
|
|
95
56
|
let readStates = StateImpl.stateTracking.get(currentEffect);
|
|
96
57
|
if (!readStates) {
|
|
97
58
|
readStates = new Set();
|
|
@@ -101,14 +62,10 @@ class StateImpl {
|
|
|
101
62
|
}
|
|
102
63
|
return this.value;
|
|
103
64
|
};
|
|
104
|
-
// Handles value updates with built-in optimizations and safeguards
|
|
105
65
|
set = (newValue) => {
|
|
106
|
-
|
|
107
|
-
if (Object.is(this.value, newValue)) {
|
|
66
|
+
if (this.equalityFn(this.value, newValue)) {
|
|
108
67
|
return;
|
|
109
68
|
}
|
|
110
|
-
// Infinite loop detection prevents direct self-mutation within effects,
|
|
111
|
-
// while allowing nested effect patterns that would otherwise appear cyclical
|
|
112
69
|
const effect = StateImpl.currentSubscriber;
|
|
113
70
|
if (effect) {
|
|
114
71
|
const states = StateImpl.stateTracking.get(effect);
|
|
@@ -117,16 +74,12 @@ class StateImpl {
|
|
|
117
74
|
}
|
|
118
75
|
}
|
|
119
76
|
this.value = newValue;
|
|
120
|
-
// Skip updates when there are no subscribers, avoiding unnecessary processing
|
|
121
77
|
if (this.subscribers.size === 0) {
|
|
122
78
|
return;
|
|
123
79
|
}
|
|
124
|
-
// Queue notifications instead of executing immediately to support batch operations
|
|
125
|
-
// and prevent redundant effect runs
|
|
126
80
|
for (const sub of this.subscribers) {
|
|
127
81
|
StateImpl.pendingSubscribers.add(sub);
|
|
128
82
|
}
|
|
129
|
-
// Immediate execution outside of batches, deferred execution inside batches
|
|
130
83
|
if (StateImpl.batchDepth === 0 && !StateImpl.isNotifying) {
|
|
131
84
|
StateImpl.notifySubscribers();
|
|
132
85
|
}
|
|
@@ -134,27 +87,17 @@ class StateImpl {
|
|
|
134
87
|
update = (fn) => {
|
|
135
88
|
this.set(fn(this.value));
|
|
136
89
|
};
|
|
137
|
-
/**
|
|
138
|
-
* Registers a function to run whenever its reactive dependencies change.
|
|
139
|
-
* Implementation of the public 'effect' function.
|
|
140
|
-
*/
|
|
141
90
|
static createEffect = (fn) => {
|
|
142
91
|
const runEffect = () => {
|
|
143
|
-
// Prevent re-entrance to avoid cascade updates during effect execution
|
|
144
92
|
if (StateImpl.activeSubscribers.has(runEffect)) {
|
|
145
93
|
return;
|
|
146
94
|
}
|
|
147
95
|
StateImpl.activeSubscribers.add(runEffect);
|
|
148
96
|
const parentEffect = StateImpl.currentSubscriber;
|
|
149
97
|
try {
|
|
150
|
-
// Clean existing subscriptions before running to ensure only
|
|
151
|
-
// currently accessed states are tracked as dependencies
|
|
152
98
|
StateImpl.cleanupEffect(runEffect);
|
|
153
|
-
// Set current context for automatic dependency tracking
|
|
154
99
|
StateImpl.currentSubscriber = runEffect;
|
|
155
100
|
StateImpl.stateTracking.set(runEffect, new Set());
|
|
156
|
-
// Track parent-child relationships to handle nested effects correctly
|
|
157
|
-
// and enable hierarchical cleanup later
|
|
158
101
|
if (parentEffect) {
|
|
159
102
|
StateImpl.parentSubscriber.set(runEffect, parentEffect);
|
|
160
103
|
let children = StateImpl.childSubscribers.get(parentEffect);
|
|
@@ -164,22 +107,17 @@ class StateImpl {
|
|
|
164
107
|
}
|
|
165
108
|
children.add(runEffect);
|
|
166
109
|
}
|
|
167
|
-
// Execute the effect function, which will auto-track dependencies
|
|
168
110
|
fn();
|
|
169
111
|
}
|
|
170
112
|
finally {
|
|
171
|
-
// Restore previous context when done
|
|
172
113
|
StateImpl.currentSubscriber = parentEffect;
|
|
173
114
|
StateImpl.activeSubscribers.delete(runEffect);
|
|
174
115
|
}
|
|
175
116
|
};
|
|
176
|
-
// Run immediately unless we're in a batch operation
|
|
177
117
|
if (StateImpl.batchDepth === 0) {
|
|
178
118
|
runEffect();
|
|
179
119
|
}
|
|
180
120
|
else {
|
|
181
|
-
// Still track parent-child relationship even when deferred,
|
|
182
|
-
// ensuring proper hierarchical cleanup later
|
|
183
121
|
if (StateImpl.currentSubscriber) {
|
|
184
122
|
const parent = StateImpl.currentSubscriber;
|
|
185
123
|
StateImpl.parentSubscriber.set(runEffect, parent);
|
|
@@ -190,17 +128,13 @@ class StateImpl {
|
|
|
190
128
|
}
|
|
191
129
|
children.add(runEffect);
|
|
192
130
|
}
|
|
193
|
-
// Queue for execution when batch completes
|
|
194
131
|
StateImpl.deferredEffectCreations.push(runEffect);
|
|
195
132
|
}
|
|
196
|
-
// Return cleanup function to properly disconnect from reactivity graph
|
|
197
133
|
return () => {
|
|
198
|
-
// Remove from dependency tracking to stop future notifications
|
|
199
134
|
StateImpl.cleanupEffect(runEffect);
|
|
200
135
|
StateImpl.pendingSubscribers.delete(runEffect);
|
|
201
136
|
StateImpl.activeSubscribers.delete(runEffect);
|
|
202
137
|
StateImpl.stateTracking.delete(runEffect);
|
|
203
|
-
// Clean up parent-child relationship bidirectionally
|
|
204
138
|
const parent = StateImpl.parentSubscriber.get(runEffect);
|
|
205
139
|
if (parent) {
|
|
206
140
|
const siblings = StateImpl.childSubscribers.get(parent);
|
|
@@ -209,8 +143,6 @@ class StateImpl {
|
|
|
209
143
|
}
|
|
210
144
|
}
|
|
211
145
|
StateImpl.parentSubscriber.delete(runEffect);
|
|
212
|
-
// Recursively clean up child effects to prevent memory leaks in
|
|
213
|
-
// nested effect scenarios
|
|
214
146
|
const children = StateImpl.childSubscribers.get(runEffect);
|
|
215
147
|
if (children) {
|
|
216
148
|
for (const child of children) {
|
|
@@ -221,19 +153,12 @@ class StateImpl {
|
|
|
221
153
|
}
|
|
222
154
|
};
|
|
223
155
|
};
|
|
224
|
-
/**
|
|
225
|
-
* Groups multiple state updates to trigger effects only once at the end.
|
|
226
|
-
* Implementation of the public 'batch' function.
|
|
227
|
-
*/
|
|
228
156
|
static executeBatch = (fn) => {
|
|
229
|
-
// Increment depth counter to handle nested batches correctly
|
|
230
157
|
StateImpl.batchDepth++;
|
|
231
158
|
try {
|
|
232
159
|
return fn();
|
|
233
160
|
}
|
|
234
161
|
catch (error) {
|
|
235
|
-
// Clean up on error to prevent stale subscribers from executing
|
|
236
|
-
// and potentially causing cascading errors
|
|
237
162
|
if (StateImpl.batchDepth === 1) {
|
|
238
163
|
StateImpl.pendingSubscribers.clear();
|
|
239
164
|
StateImpl.deferredEffectCreations.length = 0;
|
|
@@ -242,10 +167,7 @@ class StateImpl {
|
|
|
242
167
|
}
|
|
243
168
|
finally {
|
|
244
169
|
StateImpl.batchDepth--;
|
|
245
|
-
// Only process effects when exiting the outermost batch,
|
|
246
|
-
// maintaining proper execution order while avoiding redundant runs
|
|
247
170
|
if (StateImpl.batchDepth === 0) {
|
|
248
|
-
// Process effects created during the batch
|
|
249
171
|
if (StateImpl.deferredEffectCreations.length > 0) {
|
|
250
172
|
const effectsToRun = [...StateImpl.deferredEffectCreations];
|
|
251
173
|
StateImpl.deferredEffectCreations.length = 0;
|
|
@@ -253,34 +175,24 @@ class StateImpl {
|
|
|
253
175
|
effect();
|
|
254
176
|
}
|
|
255
177
|
}
|
|
256
|
-
// Process state updates that occurred during the batch
|
|
257
178
|
if (StateImpl.pendingSubscribers.size > 0 && !StateImpl.isNotifying) {
|
|
258
179
|
StateImpl.notifySubscribers();
|
|
259
180
|
}
|
|
260
181
|
}
|
|
261
182
|
}
|
|
262
183
|
};
|
|
263
|
-
/**
|
|
264
|
-
* Creates a read-only computed value that updates when its dependencies change.
|
|
265
|
-
* Implementation of the public 'derive' function.
|
|
266
|
-
*/
|
|
267
184
|
static createDerive = (computeFn) => {
|
|
268
185
|
const valueState = StateImpl.createState(undefined);
|
|
269
186
|
let initialized = false;
|
|
270
187
|
let cachedValue;
|
|
271
|
-
// Internal effect automatically tracks dependencies and updates the derived value
|
|
272
188
|
StateImpl.createEffect(() => {
|
|
273
189
|
const newValue = computeFn();
|
|
274
|
-
// Only update if the value actually changed to preserve referential equality
|
|
275
|
-
// and prevent unnecessary downstream updates
|
|
276
190
|
if (!(initialized && Object.is(cachedValue, newValue))) {
|
|
277
191
|
cachedValue = newValue;
|
|
278
192
|
valueState.set(newValue);
|
|
279
193
|
}
|
|
280
194
|
initialized = true;
|
|
281
195
|
});
|
|
282
|
-
// Return function with lazy initialization - ensures value is available
|
|
283
|
-
// even when accessed before its dependencies have had a chance to update
|
|
284
196
|
return () => {
|
|
285
197
|
if (!initialized) {
|
|
286
198
|
cachedValue = computeFn();
|
|
@@ -290,35 +202,25 @@ class StateImpl {
|
|
|
290
202
|
return valueState();
|
|
291
203
|
};
|
|
292
204
|
};
|
|
293
|
-
/**
|
|
294
|
-
* Creates an efficient subscription to a subset of a state value.
|
|
295
|
-
* Implementation of the public 'select' function.
|
|
296
|
-
*/
|
|
297
205
|
static createSelect = (source, selectorFn, equalityFn = Object.is) => {
|
|
298
206
|
let lastSourceValue;
|
|
299
207
|
let lastSelectedValue;
|
|
300
208
|
let initialized = false;
|
|
301
209
|
const valueState = StateImpl.createState(undefined);
|
|
302
|
-
// Internal effect to track the source and update only when needed
|
|
303
210
|
StateImpl.createEffect(() => {
|
|
304
211
|
const sourceValue = source();
|
|
305
|
-
// Skip computation if source reference hasn't changed
|
|
306
212
|
if (initialized && Object.is(lastSourceValue, sourceValue)) {
|
|
307
213
|
return;
|
|
308
214
|
}
|
|
309
215
|
lastSourceValue = sourceValue;
|
|
310
216
|
const newSelectedValue = selectorFn(sourceValue);
|
|
311
|
-
// Use custom equality function to determine if value semantically changed,
|
|
312
|
-
// allowing for deep equality comparisons with complex objects
|
|
313
217
|
if (initialized && lastSelectedValue !== undefined && equalityFn(lastSelectedValue, newSelectedValue)) {
|
|
314
218
|
return;
|
|
315
219
|
}
|
|
316
|
-
// Update cache and notify subscribers due the value has changed
|
|
317
220
|
lastSelectedValue = newSelectedValue;
|
|
318
221
|
valueState.set(newSelectedValue);
|
|
319
222
|
initialized = true;
|
|
320
223
|
});
|
|
321
|
-
// Return function with eager initialization capability
|
|
322
224
|
return () => {
|
|
323
225
|
if (!initialized) {
|
|
324
226
|
lastSourceValue = source();
|
|
@@ -329,12 +231,7 @@ class StateImpl {
|
|
|
329
231
|
return valueState();
|
|
330
232
|
};
|
|
331
233
|
};
|
|
332
|
-
/**
|
|
333
|
-
* Creates a lens for direct updates to nested properties of a state.
|
|
334
|
-
* Implementation of the public 'lens' function.
|
|
335
|
-
*/
|
|
336
234
|
static createLens = (source, accessor) => {
|
|
337
|
-
// Extract the property path once during lens creation
|
|
338
235
|
const extractPath = () => {
|
|
339
236
|
const path = [];
|
|
340
237
|
const proxy = new Proxy({}, {
|
|
@@ -349,17 +246,12 @@ class StateImpl {
|
|
|
349
246
|
accessor(proxy);
|
|
350
247
|
}
|
|
351
248
|
catch {
|
|
352
|
-
// Ignore errors, we're just collecting the path
|
|
353
249
|
}
|
|
354
250
|
return path;
|
|
355
251
|
};
|
|
356
|
-
// Capture the path once
|
|
357
252
|
const path = extractPath();
|
|
358
|
-
// Create a state with the initial value from the source
|
|
359
253
|
const lensState = StateImpl.createState(accessor(source()));
|
|
360
|
-
// Prevent circular updates
|
|
361
254
|
let isUpdating = false;
|
|
362
|
-
// Set up an effect to sync from source to lens
|
|
363
255
|
StateImpl.createEffect(() => {
|
|
364
256
|
if (isUpdating) {
|
|
365
257
|
return;
|
|
@@ -372,7 +264,6 @@ class StateImpl {
|
|
|
372
264
|
isUpdating = false;
|
|
373
265
|
}
|
|
374
266
|
});
|
|
375
|
-
// Override the lens state's set method to update the source
|
|
376
267
|
const originalSet = lensState.set;
|
|
377
268
|
lensState.set = (value) => {
|
|
378
269
|
if (isUpdating) {
|
|
@@ -380,35 +271,25 @@ class StateImpl {
|
|
|
380
271
|
}
|
|
381
272
|
isUpdating = true;
|
|
382
273
|
try {
|
|
383
|
-
// Update lens state
|
|
384
274
|
originalSet(value);
|
|
385
|
-
// Update source by modifying the value at path
|
|
386
275
|
source.update((current) => setValueAtPath(current, path, value));
|
|
387
276
|
}
|
|
388
277
|
finally {
|
|
389
278
|
isUpdating = false;
|
|
390
279
|
}
|
|
391
280
|
};
|
|
392
|
-
// Add update method for completeness
|
|
393
281
|
lensState.update = (fn) => {
|
|
394
282
|
lensState.set(fn(lensState()));
|
|
395
283
|
};
|
|
396
284
|
return lensState;
|
|
397
285
|
};
|
|
398
|
-
// Processes queued subscriber notifications in a controlled, non-reentrant way
|
|
399
286
|
static notifySubscribers = () => {
|
|
400
|
-
// Prevent reentrance to avoid cascading notification loops when
|
|
401
|
-
// effects trigger further state changes
|
|
402
287
|
if (StateImpl.isNotifying) {
|
|
403
288
|
return;
|
|
404
289
|
}
|
|
405
290
|
StateImpl.isNotifying = true;
|
|
406
291
|
try {
|
|
407
|
-
// Process all pending effects in batches for better perf,
|
|
408
|
-
// ensuring topological execution order is maintained
|
|
409
292
|
while (StateImpl.pendingSubscribers.size > 0) {
|
|
410
|
-
// Process in snapshot batches to prevent infinite loops
|
|
411
|
-
// when effects trigger further state changes
|
|
412
293
|
const subscribers = Array.from(StateImpl.pendingSubscribers);
|
|
413
294
|
StateImpl.pendingSubscribers.clear();
|
|
414
295
|
for (const effect of subscribers) {
|
|
@@ -420,11 +301,8 @@ class StateImpl {
|
|
|
420
301
|
StateImpl.isNotifying = false;
|
|
421
302
|
}
|
|
422
303
|
};
|
|
423
|
-
// Removes effect from dependency tracking to prevent memory leaks
|
|
424
304
|
static cleanupEffect = (effect) => {
|
|
425
|
-
// Remove from execution queue to prevent stale updates
|
|
426
305
|
StateImpl.pendingSubscribers.delete(effect);
|
|
427
|
-
// Remove bidirectional dependency references to prevent memory leaks
|
|
428
306
|
const deps = StateImpl.subscriberDependencies.get(effect);
|
|
429
307
|
if (deps) {
|
|
430
308
|
for (const subscribers of deps) {
|
|
@@ -435,72 +313,54 @@ class StateImpl {
|
|
|
435
313
|
}
|
|
436
314
|
};
|
|
437
315
|
}
|
|
438
|
-
// Helper for array updates
|
|
439
316
|
const updateArrayItem = (arr, index, value) => {
|
|
440
317
|
const copy = [...arr];
|
|
441
318
|
copy[index] = value;
|
|
442
319
|
return copy;
|
|
443
320
|
};
|
|
444
|
-
// Helper for single-level updates (optimization)
|
|
445
321
|
const updateShallowProperty = (obj, key, value) => {
|
|
446
322
|
const result = { ...obj };
|
|
447
323
|
result[key] = value;
|
|
448
324
|
return result;
|
|
449
325
|
};
|
|
450
|
-
// Helper to create the appropriate container type
|
|
451
326
|
const createContainer = (key) => {
|
|
452
327
|
const isArrayKey = typeof key === 'number' || !Number.isNaN(Number(key));
|
|
453
328
|
return isArrayKey ? [] : {};
|
|
454
329
|
};
|
|
455
|
-
// Helper for handling array path updates
|
|
456
330
|
const updateArrayPath = (array, pathSegments, value) => {
|
|
457
331
|
const index = Number(pathSegments[0]);
|
|
458
332
|
if (pathSegments.length === 1) {
|
|
459
|
-
// Simple array item update
|
|
460
333
|
return updateArrayItem(array, index, value);
|
|
461
334
|
}
|
|
462
|
-
// Nested path in array
|
|
463
335
|
const copy = [...array];
|
|
464
336
|
const nextPathSegments = pathSegments.slice(1);
|
|
465
337
|
const nextKey = nextPathSegments[0];
|
|
466
|
-
// For null/undefined values in arrays, create appropriate containers
|
|
467
338
|
let nextValue = array[index];
|
|
468
339
|
if (nextValue === undefined || nextValue === null) {
|
|
469
|
-
// Use empty object as default if nextKey is undefined
|
|
470
340
|
nextValue = nextKey !== undefined ? createContainer(nextKey) : {};
|
|
471
341
|
}
|
|
472
342
|
copy[index] = setValueAtPath(nextValue, nextPathSegments, value);
|
|
473
343
|
return copy;
|
|
474
344
|
};
|
|
475
|
-
// Helper for handling object path updates
|
|
476
345
|
const updateObjectPath = (obj, pathSegments, value) => {
|
|
477
|
-
// Ensure we have a valid key
|
|
478
346
|
const currentKey = pathSegments[0];
|
|
479
347
|
if (currentKey === undefined) {
|
|
480
|
-
// This shouldn't happen given our checks in the main function
|
|
481
348
|
return obj;
|
|
482
349
|
}
|
|
483
350
|
if (pathSegments.length === 1) {
|
|
484
|
-
// Simple object property update
|
|
485
351
|
return updateShallowProperty(obj, currentKey, value);
|
|
486
352
|
}
|
|
487
|
-
// Nested path in object
|
|
488
353
|
const nextPathSegments = pathSegments.slice(1);
|
|
489
354
|
const nextKey = nextPathSegments[0];
|
|
490
|
-
// For null/undefined values, create appropriate containers
|
|
491
355
|
let currentValue = obj[currentKey];
|
|
492
356
|
if (currentValue === undefined || currentValue === null) {
|
|
493
|
-
// Use empty object as default if nextKey is undefined
|
|
494
357
|
currentValue = nextKey !== undefined ? createContainer(nextKey) : {};
|
|
495
358
|
}
|
|
496
|
-
// Create new object with updated property
|
|
497
359
|
const result = { ...obj };
|
|
498
360
|
result[currentKey] = setValueAtPath(currentValue, nextPathSegments, value);
|
|
499
361
|
return result;
|
|
500
362
|
};
|
|
501
|
-
// Simplified function to update a nested value at a path
|
|
502
363
|
const setValueAtPath = (obj, pathSegments, value) => {
|
|
503
|
-
// Handle base cases
|
|
504
364
|
if (pathSegments.length === 0) {
|
|
505
365
|
return value;
|
|
506
366
|
}
|
|
@@ -511,7 +371,6 @@ const setValueAtPath = (obj, pathSegments, value) => {
|
|
|
511
371
|
if (currentKey === undefined) {
|
|
512
372
|
return obj;
|
|
513
373
|
}
|
|
514
|
-
// Delegate to specialized handlers based on data type
|
|
515
374
|
if (Array.isArray(obj)) {
|
|
516
375
|
return updateArrayPath(obj, pathSegments, value);
|
|
517
376
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let s=Symbol(),i=(e,t=Object.is)=>u.createState(e,t);var e=e=>u.createEffect(e),t=e=>u.executeBatch(e),r=e=>u.createDerive(e),c=(e,t,r=Object.is)=>u.createSelect(e,t,r);let a=e=>()=>e();var n=(e,t=Object.is)=>{let r=i(e,t);return[()=>a(r)(),{set:e=>r.set(e),update:e=>r.update(e)}]},b=(e,t)=>u.createLens(e,t);class u{static currentSubscriber=null;static pendingSubscribers=new Set;static isNotifying=!1;static batchDepth=0;static deferredEffectCreations=[];static activeSubscribers=new Set;static stateTracking=new WeakMap;static subscriberDependencies=new WeakMap;static parentSubscriber=new WeakMap;static childSubscribers=new WeakMap;value;subscribers=new Set;stateId=Symbol();equalityFn;constructor(e,t=Object.is){this.value=e,this.equalityFn=t}static createState=(e,t=Object.is)=>{let r=new u(e,t);e=()=>r.get();return e.set=e=>r.set(e),e.update=e=>r.update(e),e[s]=r.stateId,e};get=()=>{var r=u.currentSubscriber;if(r){this.subscribers.add(r);let e=u.subscriberDependencies.get(r),t=(e||(e=new Set,u.subscriberDependencies.set(r,e)),e.add(this.subscribers),u.stateTracking.get(r));t||(t=new Set,u.stateTracking.set(r,t)),t.add(this.stateId)}return this.value};set=e=>{if(!this.equalityFn(this.value,e)){var t=u.currentSubscriber;if(t)if(u.stateTracking.get(t)?.has(this.stateId)&&!u.parentSubscriber.get(t))throw new Error("Infinite loop detected: effect() cannot update a state() it depends on!");if(this.value=e,0!==this.subscribers.size){for(var r of this.subscribers)u.pendingSubscribers.add(r);0!==u.batchDepth||u.isNotifying||u.notifySubscribers()}}};update=e=>{this.set(e(this.value))};static createEffect=e=>{let r=()=>{if(!u.activeSubscribers.has(r)){u.activeSubscribers.add(r);var t=u.currentSubscriber;try{if(u.cleanupEffect(r),u.currentSubscriber=r,u.stateTracking.set(r,new Set),t){u.parentSubscriber.set(r,t);let e=u.childSubscribers.get(t);e||(e=new Set,u.childSubscribers.set(t,e)),e.add(r)}e()}finally{u.currentSubscriber=t,u.activeSubscribers.delete(r)}}};if(0===u.batchDepth)r();else{if(u.currentSubscriber){var t=u.currentSubscriber;u.parentSubscriber.set(r,t);let e=u.childSubscribers.get(t);e||(e=new Set,u.childSubscribers.set(t,e)),e.add(r)}u.deferredEffectCreations.push(r)}return()=>{u.cleanupEffect(r),u.pendingSubscribers.delete(r),u.activeSubscribers.delete(r),u.stateTracking.delete(r);var e=u.parentSubscriber.get(r),e=(e&&(e=u.childSubscribers.get(e))&&e.delete(r),u.parentSubscriber.delete(r),u.childSubscribers.get(r));if(e){for(var t of e)u.cleanupEffect(t);e.clear(),u.childSubscribers.delete(r)}}};static executeBatch=e=>{u.batchDepth++;try{return e()}catch(e){throw 1===u.batchDepth&&(u.pendingSubscribers.clear(),u.deferredEffectCreations.length=0),e}finally{if(u.batchDepth--,0===u.batchDepth){if(0<u.deferredEffectCreations.length){var t,e=[...u.deferredEffectCreations];u.deferredEffectCreations.length=0;for(t of e)t()}0<u.pendingSubscribers.size&&!u.isNotifying&&u.notifySubscribers()}}};static createDerive=t=>{let r=u.createState(void 0),s=!1,i;return u.createEffect(()=>{var e=t();s&&Object.is(i,e)||(i=e,r.set(e)),s=!0}),()=>(s||(i=t(),s=!0,r.set(i)),r())};static createSelect=(t,r,s=Object.is)=>{let i,c,a=!1,n=u.createState(void 0);return u.createEffect(()=>{var e=t();a&&Object.is(i,e)||(i=e,e=r(e),a&&void 0!==c&&s(c,e))||(c=e,n.set(e),a=!0)}),()=>(a||(i=t(),c=r(i),n.set(c),a=!0),n())};static createLens=(e,t)=>{let r=(()=>{let r=[],s=new Proxy({},{get:(e,t)=>("string"!=typeof t&&"number"!=typeof t||r.push(t),s)});try{t(s)}catch{}return r})(),s=u.createState(t(e())),i=!1,c=(u.createEffect(()=>{if(!i){i=!0;try{s.set(t(e()))}finally{i=!1}}}),s.set);return s.set=t=>{if(!i){i=!0;try{c(t),e.update(e=>p(e,r,t))}finally{i=!1}}},s.update=e=>{s.set(e(s()))},s};static notifySubscribers=()=>{if(!u.isNotifying){u.isNotifying=!0;try{for(;0<u.pendingSubscribers.size;){var e,t=Array.from(u.pendingSubscribers);u.pendingSubscribers.clear();for(e of t)e()}}finally{u.isNotifying=!1}}};static cleanupEffect=e=>{u.pendingSubscribers.delete(e);var t=u.subscriberDependencies.get(e);if(t){for(var r of t)r.delete(e);t.clear(),u.subscriberDependencies.delete(e)}}}let f=(e,t,r)=>{e=[...e];return e[t]=r,e},l=(e,t,r)=>{e={...e};return e[t]=r,e},d=e=>"number"==typeof e||!Number.isNaN(Number(e))?[]:{},S=(e,t,r)=>{var s=Number(t[0]);if(1===t.length)return f(e,s,r);var i=[...e],t=t.slice(1),c=t[0];let a=e[s];return null==a&&(a=void 0!==c?d(c):{}),i[s]=p(a,t,r),i},h=(e,t,r)=>{var s=t[0];if(void 0===s)return e;if(1===t.length)return l(e,s,r);var t=t.slice(1),i=t[0];let c=e[s];null==c&&(c=void 0!==i?d(i):{});i={...e};return i[s]=p(c,t,r),i},p=(e,t,r)=>0===t.length?r:null==e?p({},t,r):void 0===t[0]?e:(Array.isArray(e)?S:h)(e,t,r);export{i as state,e as effect,t as batch,r as derive,c as select,a as readonlyState,n as protectedState,b as lens};
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nerdalytics/beacon",
|
|
3
|
-
"version": "1000.
|
|
3
|
+
"version": "1000.2.1",
|
|
4
4
|
"description": "A lightweight reactive state library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "dist/src/index.js",
|
|
6
|
+
"main": "dist/src/index.min.js",
|
|
7
7
|
"types": "dist/src/index.d.ts",
|
|
8
|
-
"files": [
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
"files": ["dist/src/index.js", "dist/src/index.d.ts", "src/index.ts", "LICENSE"],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"typescript": "./src/index.ts",
|
|
12
|
+
"default": "./dist/src/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
14
15
|
"repository": {
|
|
15
16
|
"url": "git+https://github.com/nerdalytics/beacon.git",
|
|
16
17
|
"type": "git"
|
|
@@ -34,10 +35,12 @@
|
|
|
34
35
|
"test:unit:cyclic-dependency": "node --test tests/cyclic-dependency.test.ts",
|
|
35
36
|
"test:unit:deep-chain": "node --test tests/deep-chain.test.ts",
|
|
36
37
|
"test:unit:infinite-loop": "node --test tests/infinite-loop.test.ts",
|
|
38
|
+
"test:unit:custom-equality": "node --test tests/custom-equality.test.ts",
|
|
37
39
|
"benchmark": "node scripts/benchmark.ts",
|
|
38
40
|
"build": "npm run build:lts",
|
|
39
41
|
"prebuild:lts": "rm -rf dist/",
|
|
40
42
|
"build:lts": "tsc -p tsconfig.lts.json",
|
|
43
|
+
"postbuild:lts": "npx uglify-js --compress --mangle --module --toplevel --v8 --warn --source-map \"content='dist/src/index.js.map'\" --output dist/src/index.min.js dist/src/index.js",
|
|
41
44
|
"prepublishOnly": "npm run build:lts",
|
|
42
45
|
"pretest:lts": "node scripts/run-lts-tests.js",
|
|
43
46
|
"test:lts:20": "node --test dist/tests/**.js",
|
|
@@ -66,11 +69,12 @@
|
|
|
66
69
|
"license": "MIT",
|
|
67
70
|
"devDependencies": {
|
|
68
71
|
"@biomejs/biome": "1.9.4",
|
|
69
|
-
"@types/node": "22.14.
|
|
70
|
-
"typescript": "5.8.3"
|
|
72
|
+
"@types/node": "22.14.1",
|
|
73
|
+
"typescript": "5.8.3",
|
|
74
|
+
"uglify-js": "3.19.3"
|
|
71
75
|
},
|
|
72
76
|
"engines": {
|
|
73
77
|
"node": ">=20.0.0"
|
|
74
78
|
},
|
|
75
|
-
"packageManager": "npm@11.
|
|
79
|
+
"packageManager": "npm@11.3.0"
|
|
76
80
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,8 @@ export type State<T> = ReadOnlyState<T> &
|
|
|
18
18
|
/**
|
|
19
19
|
* Creates a reactive state container with the provided initial value.
|
|
20
20
|
*/
|
|
21
|
-
export const state = <T>(initialValue: T): State<T> =>
|
|
21
|
+
export const state = <T>(initialValue: T, equalityFn: (a: T, b: T) => boolean = Object.is): State<T> =>
|
|
22
|
+
StateImpl.createState(initialValue, equalityFn)
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Registers a function to run whenever its reactive dependencies change.
|
|
@@ -55,8 +56,11 @@ export const readonlyState =
|
|
|
55
56
|
/**
|
|
56
57
|
* Creates a state with access control, returning a tuple of reader and writer.
|
|
57
58
|
*/
|
|
58
|
-
export const protectedState = <T>(
|
|
59
|
-
|
|
59
|
+
export const protectedState = <T>(
|
|
60
|
+
initialValue: T,
|
|
61
|
+
equalityFn: (a: T, b: T) => boolean = Object.is
|
|
62
|
+
): [ReadOnlyState<T>, WriteableState<T>] => {
|
|
63
|
+
const fullState = state(initialValue, equalityFn)
|
|
60
64
|
return [
|
|
61
65
|
(): T => readonlyState(fullState)(),
|
|
62
66
|
{
|
|
@@ -93,17 +97,19 @@ class StateImpl<T> {
|
|
|
93
97
|
private value: T
|
|
94
98
|
private subscribers = new Set<Subscriber>()
|
|
95
99
|
private stateId = Symbol()
|
|
100
|
+
private equalityFn: (a: T, b: T) => boolean
|
|
96
101
|
|
|
97
|
-
constructor(initialValue: T) {
|
|
102
|
+
constructor(initialValue: T, equalityFn: (a: T, b: T) => boolean = Object.is) {
|
|
98
103
|
this.value = initialValue
|
|
104
|
+
this.equalityFn = equalityFn
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
/**
|
|
102
108
|
* Creates a reactive state container with the provided initial value.
|
|
103
109
|
* Implementation of the public 'state' function.
|
|
104
110
|
*/
|
|
105
|
-
static createState = <T>(initialValue: T): State<T> => {
|
|
106
|
-
const instance = new StateImpl<T>(initialValue)
|
|
111
|
+
static createState = <T>(initialValue: T, equalityFn: (a: T, b: T) => boolean = Object.is): State<T> => {
|
|
112
|
+
const instance = new StateImpl<T>(initialValue, equalityFn)
|
|
107
113
|
const get = (): T => instance.get()
|
|
108
114
|
get.set = (value: T): void => instance.set(value)
|
|
109
115
|
get.update = (fn: (currentValue: T) => T): void => instance.update(fn)
|
|
@@ -143,7 +149,7 @@ class StateImpl<T> {
|
|
|
143
149
|
// Handles value updates with built-in optimizations and safeguards
|
|
144
150
|
set = (newValue: T): void => {
|
|
145
151
|
// Skip updates for unchanged values to prevent redundant effect executions
|
|
146
|
-
if (
|
|
152
|
+
if (this.equalityFn(this.value, newValue)) {
|
|
147
153
|
return
|
|
148
154
|
}
|
|
149
155
|
|