@nerdalytics/beacon 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +187 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +129 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
- package/src/index.ts +175 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright © 2025 Denny Trebbin (nerdalytics)
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Beacon
|
|
2
|
+
|
|
3
|
+
A lightweight reactive signal library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Features](#features)
|
|
8
|
+
- [Installation](#installation)
|
|
9
|
+
- [Usage](#usage)
|
|
10
|
+
- [API](#api)
|
|
11
|
+
- [state](#statetinitialvalue-t-signalt)
|
|
12
|
+
- [derived](#derivedfn--t-signalt)
|
|
13
|
+
- [effect](#effectfn--void--void)
|
|
14
|
+
- [batch](#batchfn--t-t)
|
|
15
|
+
- [Development](#development)
|
|
16
|
+
- [Node.js LTS Compatibility](#nodejs-lts-compatibility)
|
|
17
|
+
- [Key Differences vs TC39 Proposal](#key-differences-between-my-library-and-the-tc39-proposal)
|
|
18
|
+
- [Implementation Details](#implementation-details)
|
|
19
|
+
- [FAQ](#faq)
|
|
20
|
+
- [License](#license)
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- 🔄 **Reactive signals** - Create reactive values that automatically track dependencies
|
|
25
|
+
- 🧮 **Computed values** - Derive values from other signals with automatic updates
|
|
26
|
+
- 🔍 **Fine-grained reactivity** - Dependencies are tracked precisely at the signal level
|
|
27
|
+
- 🏎️ **Efficient updates** - Only recompute values when dependencies change
|
|
28
|
+
- 📦 **Batched updates** - Group multiple updates for performance
|
|
29
|
+
- 🧹 **Automatic cleanup** - Effects and computations automatically clean up dependencies
|
|
30
|
+
- 🔁 **Cycle handling** - Safely manages cyclic dependencies without crashing
|
|
31
|
+
- 🛠️ **TypeScript-first** - Full TypeScript support with generics
|
|
32
|
+
- 🪶 **Lightweight** - Zero dependencies, < 200 LOC
|
|
33
|
+
- ✅ **Node.js compatibility** - Works with Node.js LTS v20+ and v22+
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @nerdalytics/beacon
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { state, derived, effect, batch } from "@nerdalytics/beacon";
|
|
45
|
+
|
|
46
|
+
// Create reactive state
|
|
47
|
+
const count = state(0);
|
|
48
|
+
const doubled = derived(() => count() * 2);
|
|
49
|
+
|
|
50
|
+
// Read values
|
|
51
|
+
console.log(count()); // => 0
|
|
52
|
+
console.log(doubled()); // => 0
|
|
53
|
+
|
|
54
|
+
// Setup an effect that automatically runs when dependencies change
|
|
55
|
+
// effect() returns a cleanup function that removes all subscriptions when called
|
|
56
|
+
const unsubscribe = effect(() => {
|
|
57
|
+
console.log(`Count is ${count()}, doubled is ${doubled()}`);
|
|
58
|
+
});
|
|
59
|
+
// => "Count is 0, doubled is 0" (effect runs immediately when created)
|
|
60
|
+
|
|
61
|
+
// Update values - effect automatically runs after each change
|
|
62
|
+
count.set(5);
|
|
63
|
+
// => "Count is 5, doubled is 10"
|
|
64
|
+
|
|
65
|
+
// Update with a function
|
|
66
|
+
count.update((n) => n + 1);
|
|
67
|
+
// => "Count is 6, doubled is 12"
|
|
68
|
+
|
|
69
|
+
// Batch updates (only triggers effects once at the end)
|
|
70
|
+
batch(() => {
|
|
71
|
+
count.set(10);
|
|
72
|
+
count.set(20);
|
|
73
|
+
});
|
|
74
|
+
// => "Count is 20, doubled is 40" (only once)
|
|
75
|
+
|
|
76
|
+
// Unsubscribe the effect to stop it from running on future updates
|
|
77
|
+
// and clean up all its internal subscriptions
|
|
78
|
+
unsubscribe();
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## API
|
|
82
|
+
|
|
83
|
+
### `state<T>(initialValue: T): Signal<T>`
|
|
84
|
+
|
|
85
|
+
Creates a new reactive signal with the given initial value.
|
|
86
|
+
|
|
87
|
+
### `derived<T>(fn: () => T): Signal<T>`
|
|
88
|
+
|
|
89
|
+
Creates a derived signal that updates when its dependencies change.
|
|
90
|
+
|
|
91
|
+
### `effect(fn: () => void): () => void`
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
### `batch<T>(fn: () => T): T`
|
|
96
|
+
|
|
97
|
+
Batches multiple updates to only trigger effects once at the end.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Install dependencies
|
|
103
|
+
npm install
|
|
104
|
+
|
|
105
|
+
# Run all tests
|
|
106
|
+
npm test
|
|
107
|
+
|
|
108
|
+
# Run all tests with coverage
|
|
109
|
+
npm run test:coverage
|
|
110
|
+
|
|
111
|
+
# Run specific test suites
|
|
112
|
+
# Core functionality
|
|
113
|
+
npm run test:unit:state
|
|
114
|
+
npm run test:unit:derived
|
|
115
|
+
npm run test:unit:effect
|
|
116
|
+
npm run test:unit:batch
|
|
117
|
+
|
|
118
|
+
# Advanced patterns
|
|
119
|
+
npm run test:unit:cleanup # Tests for effect cleanup behavior
|
|
120
|
+
npm run test:unit:cyclic # Tests for cyclic dependency handling
|
|
121
|
+
|
|
122
|
+
# Format code
|
|
123
|
+
npm run format
|
|
124
|
+
|
|
125
|
+
# Build for Node.js LTS compatibility (v20+)
|
|
126
|
+
npm run build:lts
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Node.js LTS Compatibility
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
133
|
+
## Key Differences Between My Library and the [TC39 Proposal][1]
|
|
134
|
+
|
|
135
|
+
| Aspect | @nerdalytics/beacon | TC39 Proposal |
|
|
136
|
+
|--------|---------------------|---------------|
|
|
137
|
+
| **API Style** | Functional approach (`state()`, `derived()`) | Class-based design (`Signal.State`, `Signal.Computed`) |
|
|
138
|
+
| **Reading/Writing Pattern** | Function call for reading (`count()`), methods for writing (`count.set(5)`) | Method-based access (`get()`/`set()`) |
|
|
139
|
+
| **Framework Support** | High-level abstractions like `effect()` and `batch()` | Lower-level primitives (`Signal.subtle.Watcher`) that frameworks build upon |
|
|
140
|
+
| **Advanced Features** | Focused on core reactivity | Includes introspection capabilities, watched/unwatched callbacks, and Signal.subtle namespace |
|
|
141
|
+
| **Scope and Purpose** | Practical Node.js use cases with minimal API surface | Standardization with robust interoperability between frameworks |
|
|
142
|
+
|
|
143
|
+
## Implementation Details
|
|
144
|
+
|
|
145
|
+
Beacon is designed with a focus on simplicity, performance, and robust handling of complex dependency scenarios.
|
|
146
|
+
|
|
147
|
+
### Key Implementation Concepts
|
|
148
|
+
|
|
149
|
+
- **Fine-grained reactivity**: Dependencies are tracked automatically at the signal level
|
|
150
|
+
- **Efficient updates**: Changes only propagate to affected parts of the dependency graph
|
|
151
|
+
- **Cyclical dependency handling**: Robust handling of circular references without crashing
|
|
152
|
+
- **Memory management**: Automatic cleanup of subscriptions when effects are disposed
|
|
153
|
+
|
|
154
|
+
For an in-depth explanation of Beacon's internal architecture, advanced features, and best practices for handling complex scenarios like cyclical dependencies, see the [TECHNICAL_DETAILS.md][2] document.
|
|
155
|
+
|
|
156
|
+
## FAQ
|
|
157
|
+
|
|
158
|
+
<details>
|
|
159
|
+
|
|
160
|
+
<summary>Why "Beacon" Instead of "Signal"?</summary>
|
|
161
|
+
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 "Signal" helps avoid confusion with the TC39 proposal and similar libraries while still accurately describing the core functionality.
|
|
162
|
+
|
|
163
|
+
</details>
|
|
164
|
+
|
|
165
|
+
<details>
|
|
166
|
+
|
|
167
|
+
<summary>How does Beacon handle infinite update cycles?</summary>
|
|
168
|
+
Beacon uses a queue-based update system that won't crash even with cyclical dependencies. If signals form a cycle where values constantly change (A updates B updates A...), the system will continue processing these updates without stack overflows. However, this could potentially affect performance if updates never stabilize. See the [TECHNICAL_DETAILS.md][4] document for best practices on handling cyclical dependencies.
|
|
169
|
+
|
|
170
|
+
</details>
|
|
171
|
+
|
|
172
|
+
<details>
|
|
173
|
+
|
|
174
|
+
<summary>How performant is Beacon?</summary>
|
|
175
|
+
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 signals.
|
|
176
|
+
|
|
177
|
+
</details>
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
This project is licensed under the MIT License. See the [LICENSE][3] file for details.
|
|
182
|
+
|
|
183
|
+
<!-- Links collection -->
|
|
184
|
+
|
|
185
|
+
[1]: https://github.com/tc39/proposal-signals
|
|
186
|
+
[2]: ./TECHNICAL_DETAILS.md
|
|
187
|
+
[3]: ./LICENSE
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type Unsubscribe = () => void;
|
|
2
|
+
export interface Signal<T> {
|
|
3
|
+
(): T;
|
|
4
|
+
set(value: T): void;
|
|
5
|
+
update(fn: (currentValue: T) => T): void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Creates a new reactive state with the provided initial value
|
|
9
|
+
*/
|
|
10
|
+
export declare const state: <T>(initialValue: T) => Signal<T>;
|
|
11
|
+
/**
|
|
12
|
+
* Creates an effect that runs when its dependencies change
|
|
13
|
+
*/
|
|
14
|
+
export declare const effect: (fn: () => void) => Unsubscribe;
|
|
15
|
+
/**
|
|
16
|
+
* Creates a derived signal that computes its value from other signals
|
|
17
|
+
*/
|
|
18
|
+
export declare const derived: <T>(fn: () => T) => Signal<T>;
|
|
19
|
+
/**
|
|
20
|
+
* Batches multiple updates to run effects only once at the end
|
|
21
|
+
*/
|
|
22
|
+
export declare const batch: <T>(fn: () => T) => T;
|
|
23
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Global state for tracking
|
|
2
|
+
let currentEffect = null;
|
|
3
|
+
let batchDepth = 0;
|
|
4
|
+
const pendingEffects = new Set();
|
|
5
|
+
const subscriberDependencies = new WeakMap();
|
|
6
|
+
// Use a flag to prevent multiple updates from running the effects
|
|
7
|
+
let updateInProgress = false;
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new reactive state with the provided initial value
|
|
10
|
+
*/
|
|
11
|
+
export const state = (initialValue) => {
|
|
12
|
+
let value = initialValue;
|
|
13
|
+
const subscribers = new Set();
|
|
14
|
+
const read = () => {
|
|
15
|
+
if (currentEffect) {
|
|
16
|
+
subscribers.add(currentEffect);
|
|
17
|
+
let dependencies = subscriberDependencies.get(currentEffect);
|
|
18
|
+
if (!dependencies) {
|
|
19
|
+
dependencies = new Set();
|
|
20
|
+
subscriberDependencies.set(currentEffect, dependencies);
|
|
21
|
+
}
|
|
22
|
+
dependencies.add(subscribers);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
const write = (newValue) => {
|
|
27
|
+
if (Object.is(value, newValue)) {
|
|
28
|
+
return; // No change
|
|
29
|
+
}
|
|
30
|
+
value = newValue;
|
|
31
|
+
if (subscribers.size === 0) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Add subscribers to pendingEffects - always use loop for better performance
|
|
35
|
+
for (const sub of subscribers) {
|
|
36
|
+
pendingEffects.add(sub);
|
|
37
|
+
}
|
|
38
|
+
if (batchDepth === 0 && !updateInProgress) {
|
|
39
|
+
processEffects();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const update = (fn) => {
|
|
43
|
+
write(fn(value));
|
|
44
|
+
};
|
|
45
|
+
return Object.assign(read, { set: write, update });
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Process all pending effects, ensuring full propagation through the dependency chain
|
|
49
|
+
*/
|
|
50
|
+
const processEffects = () => {
|
|
51
|
+
if (pendingEffects.size === 0 || updateInProgress) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
updateInProgress = true;
|
|
55
|
+
while (pendingEffects.size > 0) {
|
|
56
|
+
const currentEffects = [...pendingEffects];
|
|
57
|
+
pendingEffects.clear();
|
|
58
|
+
for (const effect of currentEffects) {
|
|
59
|
+
effect();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
updateInProgress = false;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Helper to clean up effect subscriptions
|
|
66
|
+
*/
|
|
67
|
+
const cleanupEffect = (effect) => {
|
|
68
|
+
const deps = subscriberDependencies.get(effect);
|
|
69
|
+
if (deps) {
|
|
70
|
+
for (const subscribers of deps) {
|
|
71
|
+
subscribers.delete(effect);
|
|
72
|
+
}
|
|
73
|
+
deps.clear();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Creates an effect that runs when its dependencies change
|
|
78
|
+
*/
|
|
79
|
+
export const effect = (fn) => {
|
|
80
|
+
const runEffect = () => {
|
|
81
|
+
cleanupEffect(runEffect);
|
|
82
|
+
const prevEffect = currentEffect;
|
|
83
|
+
currentEffect = runEffect;
|
|
84
|
+
try {
|
|
85
|
+
fn();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
currentEffect = prevEffect;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
runEffect();
|
|
92
|
+
return () => {
|
|
93
|
+
cleanupEffect(runEffect);
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Creates a derived signal that computes its value from other signals
|
|
98
|
+
*/
|
|
99
|
+
export const derived = (fn) => {
|
|
100
|
+
// Initialize signal with the computed value
|
|
101
|
+
const signal = state(fn());
|
|
102
|
+
// Only run fn() again when dependencies change
|
|
103
|
+
effect(() => {
|
|
104
|
+
signal.set(fn());
|
|
105
|
+
});
|
|
106
|
+
return signal;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Batches multiple updates to run effects only once at the end
|
|
110
|
+
*/
|
|
111
|
+
export const batch = (fn) => {
|
|
112
|
+
batchDepth++;
|
|
113
|
+
try {
|
|
114
|
+
return fn();
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (batchDepth === 1) {
|
|
118
|
+
pendingEffects.clear();
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
batchDepth--;
|
|
124
|
+
if (batchDepth === 0 && pendingEffects.size > 0) {
|
|
125
|
+
processEffects();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nerdalytics/beacon",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight reactive signal library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/",
|
|
10
|
+
"src/",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test --experimental-config-file=node.config.json \"test/**/*.test.ts\"",
|
|
15
|
+
"test:coverage": "node --test --experimental-config-file=node.config.json --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info \"test/**/*.test.ts\"",
|
|
16
|
+
"test:unit": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^(State|Derived|Effect|Batch|Cleanup|Cyclic Dependencies)$/\"",
|
|
17
|
+
"test:unit:state": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^State$/\"",
|
|
18
|
+
"test:unit:derived": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Derived$/\"",
|
|
19
|
+
"test:unit:effect": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Effect$/\"",
|
|
20
|
+
"test:unit:batch": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Batch$/\"",
|
|
21
|
+
"test:unit:cleanup": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Cleanup$/\"",
|
|
22
|
+
"test:unit:cyclic": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Cyclic Dependencies$/\"",
|
|
23
|
+
"test:integration": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Deep Dependency Chains$/\"",
|
|
24
|
+
"test:perf": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Performance$/\"",
|
|
25
|
+
"test:perf:update-docs": "node scripts/update-performance-docs.ts",
|
|
26
|
+
"format": "biome format --write .",
|
|
27
|
+
"prebuild": "rm -rf dist/",
|
|
28
|
+
"build": "npm run build:lts",
|
|
29
|
+
"build:lts": "tsc -p tsconfig.build.json",
|
|
30
|
+
"prepublishOnly": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"reactive",
|
|
34
|
+
"signals",
|
|
35
|
+
"state-management",
|
|
36
|
+
"nodejs",
|
|
37
|
+
"typescript",
|
|
38
|
+
"reactive-programming",
|
|
39
|
+
"dependency-tracking",
|
|
40
|
+
"esm",
|
|
41
|
+
"observable",
|
|
42
|
+
"backend",
|
|
43
|
+
"server-side",
|
|
44
|
+
"computed-values",
|
|
45
|
+
"effects",
|
|
46
|
+
"batching"
|
|
47
|
+
],
|
|
48
|
+
"author": "Denny Trebbin",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@biomejs/biome": "1.9.4",
|
|
52
|
+
"@types/node": "22.13.16",
|
|
53
|
+
"typescript": "5.8.2"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
type Subscriber = () => void
|
|
3
|
+
|
|
4
|
+
type Unsubscribe = () => void
|
|
5
|
+
|
|
6
|
+
export interface Signal<T> {
|
|
7
|
+
(): T // get value
|
|
8
|
+
set(value: T): void // set value directly
|
|
9
|
+
update(fn: (currentValue: T) => T): void // update value with a function
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Global state for tracking
|
|
13
|
+
let currentEffect: Subscriber | null = null
|
|
14
|
+
|
|
15
|
+
let batchDepth = 0
|
|
16
|
+
|
|
17
|
+
const pendingEffects = new Set<Subscriber>()
|
|
18
|
+
|
|
19
|
+
const subscriberDependencies = new WeakMap<Subscriber, Set<Set<Subscriber>>>()
|
|
20
|
+
|
|
21
|
+
// Use a flag to prevent multiple updates from running the effects
|
|
22
|
+
let updateInProgress = false
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a new reactive state with the provided initial value
|
|
26
|
+
*/
|
|
27
|
+
export const state = <T>(initialValue: T): Signal<T> => {
|
|
28
|
+
let value = initialValue
|
|
29
|
+
|
|
30
|
+
const subscribers = new Set<Subscriber>()
|
|
31
|
+
|
|
32
|
+
const read = (): T => {
|
|
33
|
+
if (currentEffect) {
|
|
34
|
+
subscribers.add(currentEffect)
|
|
35
|
+
|
|
36
|
+
let dependencies = subscriberDependencies.get(currentEffect)
|
|
37
|
+
|
|
38
|
+
if (!dependencies) {
|
|
39
|
+
dependencies = new Set()
|
|
40
|
+
|
|
41
|
+
subscriberDependencies.set(currentEffect, dependencies)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
dependencies.add(subscribers)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const write = (newValue: T): void => {
|
|
51
|
+
if (Object.is(value, newValue)) {
|
|
52
|
+
return // No change
|
|
53
|
+
}
|
|
54
|
+
value = newValue
|
|
55
|
+
|
|
56
|
+
if (subscribers.size === 0) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Add subscribers to pendingEffects - always use loop for better performance
|
|
61
|
+
for (const sub of subscribers) {
|
|
62
|
+
pendingEffects.add(sub)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (batchDepth === 0 && !updateInProgress) {
|
|
66
|
+
processEffects()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const update = (fn: (currentValue: T) => T): void => {
|
|
71
|
+
write(fn(value))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Object.assign(read, { set: write, update })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Process all pending effects, ensuring full propagation through the dependency chain
|
|
79
|
+
*/
|
|
80
|
+
const processEffects = (): void => {
|
|
81
|
+
if (pendingEffects.size === 0 || updateInProgress) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
updateInProgress = true
|
|
86
|
+
|
|
87
|
+
while (pendingEffects.size > 0) {
|
|
88
|
+
const currentEffects = [...pendingEffects]
|
|
89
|
+
pendingEffects.clear()
|
|
90
|
+
|
|
91
|
+
for (const effect of currentEffects) {
|
|
92
|
+
effect()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
updateInProgress = false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Helper to clean up effect subscriptions
|
|
101
|
+
*/
|
|
102
|
+
const cleanupEffect = (effect: Subscriber): void => {
|
|
103
|
+
const deps = subscriberDependencies.get(effect)
|
|
104
|
+
|
|
105
|
+
if (deps) {
|
|
106
|
+
for (const subscribers of deps) {
|
|
107
|
+
subscribers.delete(effect)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
deps.clear()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Creates an effect that runs when its dependencies change
|
|
116
|
+
*/
|
|
117
|
+
export const effect = (fn: () => void): Unsubscribe => {
|
|
118
|
+
const runEffect = (): void => {
|
|
119
|
+
cleanupEffect(runEffect)
|
|
120
|
+
|
|
121
|
+
const prevEffect = currentEffect
|
|
122
|
+
|
|
123
|
+
currentEffect = runEffect
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
fn()
|
|
127
|
+
} finally {
|
|
128
|
+
currentEffect = prevEffect
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
runEffect()
|
|
133
|
+
|
|
134
|
+
return (): void => {
|
|
135
|
+
cleanupEffect(runEffect)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Creates a derived signal that computes its value from other signals
|
|
141
|
+
*/
|
|
142
|
+
export const derived = <T>(fn: () => T): Signal<T> => {
|
|
143
|
+
// Initialize signal with the computed value
|
|
144
|
+
const signal = state<T>(fn())
|
|
145
|
+
|
|
146
|
+
// Only run fn() again when dependencies change
|
|
147
|
+
effect((): void => {
|
|
148
|
+
signal.set(fn())
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return signal
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Batches multiple updates to run effects only once at the end
|
|
156
|
+
*/
|
|
157
|
+
export const batch = <T>(fn: () => T): T => {
|
|
158
|
+
batchDepth++
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
return fn()
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (batchDepth === 1) {
|
|
164
|
+
pendingEffects.clear()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw error
|
|
168
|
+
} finally {
|
|
169
|
+
batchDepth--
|
|
170
|
+
|
|
171
|
+
if (batchDepth === 0 && pendingEffects.size > 0) {
|
|
172
|
+
processEffects()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|