@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 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
+ [![license:mit](https://flat.badgen.net/static/license/MIT/blue)](https://github.com/nerdalytics/beacon/blob/trunk/LICENSE)
6
+ [![registry:npm:version](https://img.shields.io/npm/v/@nerdalytics/beacon.svg)](https://www.npmjs.com/package/@nerdalytics/beacon)
7
+
8
+ [![tech:nodejs](https://img.shields.io/badge/Node%20js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
9
+ [![language:typescript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://typescriptlang.org/)
10
+ [![linter:biome](https://img.shields.io/badge/biome-60a5fa?style=for-the-badge&logo=biome&logoColor=white)](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
- ## Table of Contents
14
+ <details>
15
+ <summary><Strong>Table of Contents</Strong></summary>
6
16
 
7
17
  - [Features](#features)
8
- - [Installation](#installation)
9
- - [Usage](#usage)
10
- - [API](#api)
11
- - [state](#statetinitialvalue-t-statet)
12
- - [derive](#derivetfn---t-readonlystatet)
13
- - [effect](#effectfn---void---void)
14
- - [batch](#batchtfn---t-t)
15
- - [select](#selectt-rsource-readonlystatet-selectorfn-state-t--r-equalityfn-a-r-b-r--boolean-readonlystater)
16
- - [lens](#lenst-ksource-statet-accessor-state-t--k-statek)
17
- - [readonlyState](#readonlystatetstate-statet-readonlystatet)
18
- - [protectedState](#protectedstatetinitialvalue-t-readonlystatet-writeablestatet)
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
- - [Node.js LTS Compatibility](#nodejs-lts-compatibility)
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, < 200 LOC
60
+ - 🪶 **Lightweight** - Zero dependencies
39
61
  - ✅ **Node.js compatibility** - Works with Node.js LTS v20+ and v22+
40
62
 
41
- ## Installation
63
+ ## Quick Start
42
64
 
43
- ```sh
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, batch, select, readonlyState, protectedState } from "@nerdalytics/beacon";
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
- // Read values
57
- console.log(count()); // => 0
58
- console.log(doubled()); // => 0
75
+ // Create a derived value
76
+ const doubled = derive(() => count() * 2);
59
77
 
60
- // Setup an effect that automatically runs when dependencies change
61
- // effect() returns a cleanup function that removes all subscriptions when called
62
- const unsubscribe = effect(() => {
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 is 0, doubled is 0" (effect runs immediately when created)
82
+ // => "Count: 0, Doubled: 0"
66
83
 
67
- // Update values - effect automatically runs after each change
84
+ // Update the state - effect runs automatically
68
85
  count.set(5);
69
- // => "Count is 5, doubled is 10"
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
- count.update((n) => n + 1);
73
- // => "Count is 6, doubled is 12"
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 20, doubled is 40" (only once)
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
- // Using select to subscribe to specific parts of state
83
- const user = state({ name: "Alice", age: 30, email: "alice@example.com" });
84
- const nameSelector = select(user, u => u.name);
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 changed: ${nameSelector()}`);
237
+ console.log(`Name: ${nameState()}`);
88
238
  });
89
- // => "Name changed: Alice"
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
- // Updates to the selected property will trigger the effect
92
- user.update(u => ({ ...u, name: "Bob" }));
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
- // Updates to other properties won't trigger the effect
96
- user.update(u => ({ ...u, age: 31 })); // No effect triggered
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
- // Unsubscribe the effect to stop it from running on future updates
122
- // and clean up all its internal subscriptions
123
- unsubscribe();
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
- // Using protectedState to separate read and write capabilities
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
- // getUser() works to read the state
133
- // setUser.set() and setUser.update() work to modify the state
134
- // but getUser has no mutation methods
135
-
136
- // Infinite loop detection example (would throw an error)
137
- try {
138
- effect(() => {
139
- const value = counter();
140
- // The following would throw an error because it attempts to
141
- // update a state that the effect depends on:
142
- // "Infinite loop detected: effect() cannot update a state() it depends on!"
143
- // counter.set(value + 1);
144
-
145
- // Instead, use a safe pattern with proper dependencies:
146
- console.log(`Current counter value: ${value}`);
147
- });
148
- } catch (error) {
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
- ## API
350
+ ## Advanced Features
154
351
 
155
- ### `state<T>(initialValue: T): State<T>`
352
+ Beacon includes several advanced capabilities that help you build robust applications.
156
353
 
157
- Creates a new reactive state container with the provided initial value.
354
+ ### Infinite Loop Protection
158
355
 
159
- ### `derive<T>(fn: () => T): ReadOnlyState<T>`
356
+ Beacon prevents common mistakes that could cause infinite loops:
160
357
 
161
- Creates a read-only computed value that updates when its dependencies change.
358
+ ```typescript
359
+ import { state, effect } from '@nerdalytics/beacon';
162
360
 
163
- ### `effect(fn: () => void): () => void`
361
+ const counter = state(0);
164
362
 
165
- Creates an effect that runs the given function immediately and whenever its dependencies change. Returns an unsubscribe function that stops the effect and cleans up all subscriptions when called.
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
- ### `batch<T>(fn: () => T): T`
369
+ // Instead, use proper patterns like:
370
+ const increment = () => counter.update(n => n + 1);
371
+ ```
168
372
 
169
- Batches multiple updates to only trigger effects once at the end.
373
+ ### Automatic Cleanup
170
374
 
171
- ### `select<T, R>(source: ReadOnlyState<T>, selectorFn: (state: T) => R, equalityFn?: (a: R, b: R) => boolean): ReadOnlyState<R>`
375
+ All subscriptions are automatically cleaned up when effects are unsubscribed:
172
376
 
173
- Creates an efficient subscription to a subset of a state value. The selector will only notify its subscribers when the selected value actually changes according to the provided equality function (defaults to `Object.is`).
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
- ### `lens<T, K>(source: State<T>, accessor: (state: T) => K): State<K>`
394
+ // Unsubscribe cleans up everything, including nested effects
395
+ cleanup();
396
+ ```
176
397
 
177
- Creates a lens for direct updates to nested properties of a state. A lens combines the functionality of `select` (for reading) with the ability to update the nested property while maintaining referential integrity throughout the object tree.
398
+ ### Custom Equality Functions
178
399
 
179
- ### `readonlyState<T>(state: State<T>): ReadOnlyState<T>`
400
+ Control when subscribers are notified with custom equality checks. You can provide custom equality functions to `state`, `select`, and `protectedState`:
180
401
 
181
- Creates a read-only view of a state, hiding mutation methods. Useful when you want to expose a state to other parts of your application without allowing direct mutations.
402
+ ```typescript
403
+ import { state, select, effect, protectedState } from '@nerdalytics/beacon';
182
404
 
183
- ### `protectedState<T>(initialValue: T): [ReadOnlyState<T>, WriteableState<T>]`
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
- Creates a state with access control, returning a tuple of reader and writer. This pattern separates read and write capabilities, allowing you to expose only the reading capability to consuming code while keeping the writing capability private.
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
- ## Development
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
- ```sh
190
- # Install dependencies
191
- npm install
419
+ // Custom equality with select
420
+ const list = state([1, 2, 3]);
192
421
 
193
- # Run all tests
194
- npm test
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
- # Run all tests with coverage
197
- npm run test:coverage
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
- ### Node.js LTS Compatibility
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 supports the two most recent Node.js LTS versions (currently v20 and v22). When the package is published to npm, it includes transpiled code compatible with these LTS versions.
459
+ Beacon actively detects when an effect tries to update a state it depends on, preventing common infinite update cycles:
228
460
 
229
- ## Key Differences Between My Library and the [TC39 Proposal][1]
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
- | Aspect | @nerdalytics/beacon | TC39 Proposal |
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
- ## Implementation Details
471
+ Beacon employs two complementary strategies for handling cyclical updates:
240
472
 
241
- Beacon is designed with a focus on simplicity, performance, and robust handling of complex dependency scenarios.
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
- ### Key Implementation Concepts
476
+ ## Development
244
477
 
245
- - **Fine-grained reactivity**: Dependencies are tracked automatically at the state level
246
- - **Efficient updates**: Changes only propagate to affected parts of the dependency graph
247
- - **Cyclical dependency handling**: Robust handling of circular references without crashing
248
- - **Infinite loop detection**: Safeguards against direct self-mutation within effects
249
- - **Memory management**: Automatic cleanup of subscriptions when effects are disposed
250
- - **Optimized batching**: Smart scheduling of updates to minimize redundant computations
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
- <details>
498
+ #### Why "Beacon" Instead of "Signal"?
255
499
 
256
- <summary>Why "Beacon" Instead of "Signal"?</summary>
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
- </details>
502
+ #### How does Beacon handle memory management?
260
503
 
261
- <details>
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
- <summary>How does Beacon handle infinite update cycles?</summary>
264
- Beacon employs two complementary strategies for handling cyclical updates:
506
+ #### Can I use Beacon with Express or other frameworks?
265
507
 
266
- 1. **Infinite Loop Detection**: Beacon actively detects direct infinite loops in effects by tracking which states an effect reads and writes to. If an effect attempts to update a state it depends on (directly modifying its own dependency), Beacon throws an error with a clear message: "Infinite loop detected: effect() cannot update a state() it depends on!"
508
+ Yes! Beacon works well as a state management solution in any Node.js application:
267
509
 
268
- 2. **Safe Cyclic Dependencies**: 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.
510
+ ```typescript
511
+ import express from 'express';
512
+ import { state, effect } from '@nerdalytics/beacon';
269
513
 
270
- This dual approach prevents accidental infinite loops while still supporting legitimate cyclic update patterns that eventually stabilize.
514
+ const app = express();
515
+ const stats = state({ requests: 0, errors: 0 });
271
516
 
272
- </details>
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
- <details>
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
- <summary>How performant is Beacon?</summary>
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
- </details>
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
 
@@ -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
- * Creates a state with access control, returning a tuple of reader and writer.
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
- constructor(initialValue) {
33
+ equalityFn;
34
+ constructor(initialValue, equalityFn = Object.is) {
64
35
  this.value = initialValue;
36
+ this.equalityFn = equalityFn;
65
37
  }
66
- /**
67
- * Creates a reactive state container with the provided initial value.
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
- // Skip updates for unchanged values to prevent redundant effect executions
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.1.1",
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
- "dist/src/index.js",
10
- "dist/src/index.d.ts",
11
- "src/index.ts",
12
- "LICENSE"
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.0",
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.2.0"
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> => StateImpl.createState(initialValue)
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>(initialValue: T): [ReadOnlyState<T>, WriteableState<T>] => {
59
- const fullState = state(initialValue)
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 (Object.is(this.value, newValue)) {
152
+ if (this.equalityFn(this.value, newValue)) {
147
153
  return
148
154
  }
149
155