avosignals 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anatoli Radulov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # avosignals
2
+
3
+ A lightweight, type-safe reactive state management library for TypeScript. It features automatic dependency tracking, efficient updates, and first-class support for **Lit** components.
4
+
5
+ # Features
6
+
7
+ - ⚡️ **Fine-Grained Reactivity**: Updates only what needs to change.
8
+ - 🛡️ **Circular Dependency Protection**: Detects and prevents infinite loops during computation.
9
+
10
+ - 🧹**Automatic Garbage Collection**: Uses `WeakRef` for internal computed subscriptions to prevent memory leaks in derived state graphs.
11
+
12
+ - 🔒 **Safety Guardrails**: Prevents state mutations during reactive evaluations to ensure data consistency.
13
+
14
+ - 🔥 **Lit Integration**: Includes a specific controller (`SignalWatcher`) to make Lit components reactive automatically.
15
+
16
+ # Instalation
17
+
18
+ ### Node / NPM
19
+ ```bash
20
+ npm install avosignals
21
+ ```
22
+
23
+ ### Browser / Esm.sh
24
+ ```html
25
+ <script type="module">
26
+ import { Signal, Computed, effect } from "https://esm.sh/avosignals"
27
+ </script>
28
+ ```
29
+
30
+ # Core Concepts
31
+ 1. Signals
32
+
33
+ Signals are the atoms of state. They hold a value and notify subscribers when that value changes.
34
+
35
+ ```Typescript
36
+ import { Signal } from 'avosignals';
37
+
38
+ const count = new Signal(0, 'count');
39
+
40
+ console.log(count.get()); // 0
41
+
42
+ count.set(1);
43
+ count.update(c => c + 1); // 2
44
+ ```
45
+
46
+ 2. Computed
47
+
48
+ Computed values are derived signals. They depend on other signals and re-evaluate only when their dependencies change. They are lazy—they only recalculate when read.
49
+
50
+ ```Typescript
51
+ import { Signal, Computed } from 'avosignals';
52
+
53
+ const count = new Signal(1);
54
+ const double = new Computed(() => count.get() * 2, 'double');
55
+
56
+ console.log(double.get()); // 2
57
+
58
+ count.set(10);
59
+ console.log(double.get()); // 20
60
+ ```
61
+
62
+ 3. Effects
63
+
64
+ Effects are side effects that run automatically whenever the signals they access change. Useful for logging, manual DOM manipulation, or syncing with external APIs.
65
+
66
+ ```Typescript
67
+ import { Signal, effect } from 'avosignals';
68
+
69
+ const count = new Signal(0);
70
+
71
+ const dispose = effect(() => {
72
+ console.log(`The count is now ${count.get()}`);
73
+
74
+ // Optional cleanup function (runs before next execution or on dispose)
75
+ return () => console.log('Cleaning up...');
76
+ });
77
+
78
+ count.set(1);
79
+ // Logs: "The count is now 1"
80
+
81
+ dispose();
82
+ // Logs: 'Cleaning up...'
83
+ ```
84
+
85
+ # Usage with Lit
86
+
87
+ `avosignals` was built with Lit in mind. The `SignalWatcher` class hooks into the Lit lifecycle to automatically track signals accessed during render. Its core design is to allow for production ready signals which can be easily replaced with Lit's official signals once **TC39 signals** becomes mainstream and production ready.
88
+
89
+ # The `SignalWatcher` Controller
90
+
91
+ You do not need to manually subscribe to signals. simply add the controller, and any signal read inside `render()` will trigger a component update when it changes.
92
+
93
+ ```Typescript
94
+ import { LitElement, html } from 'lit';
95
+ import { customElement } from 'lit/decorators.js';
96
+ import { Signal, SignalWatcher } from 'avosignals';
97
+
98
+ // Shared state
99
+ const counter = new Signal(0);
100
+
101
+ @customElement('my-counter')
102
+ export class MyCounter extends LitElement {
103
+ // 1. Register the watcher
104
+ private watcher = new SignalWatcher(this);
105
+
106
+ render() {
107
+ // 2. Access signals directly.
108
+ // The component now auto-subscribes to 'counter'.
109
+ return html`
110
+ <p>Count: ${counter.get()}</p>
111
+ <button @click=${() => counter.update(c => c + 1)}>
112
+ Increment
113
+ </button>
114
+ `;
115
+ }
116
+ }
117
+ ```
118
+
119
+ # Advanced Architecture
120
+
121
+ ### Memory Management (WeakRefs)
122
+
123
+ Unlike many other signal libraries, `Computed` nodes in avosignals hold weak references to their subscribers where possible. This means if you create a derived signal but stop referencing it in your application, JavaScript's Garbage Collector can clean it up, even if the source signal is still active. This prevents the common "detached listener" memory leaks found in observer patterns.
124
+
125
+ ### Cycle Detection
126
+
127
+ `avosignals` maintains a stack of active consumers. If a computed value attempts to read itself during its own evaluation **(A -> B -> A)**, the library throws a descriptive error helping you identify the cycle immediately.
128
+
129
+ ### Read/Write Consistency
130
+
131
+ To ensure unidirectional data flow, `avosignals` forbids writing to a `Signal` while a `Computed` value is currently being evaluated. This prevents side-effects from occurring during the "read" phase of your application loop.
132
+
133
+ # API Reference
134
+
135
+ `Signal<T>`
136
+
137
+ - `constructor(initial: T, name?: string)`
138
+ - `get(): T`: Returns current value and tracks dependency.
139
+ - `set(value: T)`: Updates value and notifies listeners.
140
+ - `update(fn: (prev: T) => T)`: Convenience method for updating based on previous value.
141
+
142
+ `Computed<T>`
143
+ - `constructor(fn: () => T, name?: string)`
144
+ - `get(): T`: Evaluates (if dirty) and returns the value.
145
+
146
+ `effect`
147
+ - `effect(fn: () => void | cleanupFn)`: Runs immediately and tracks dependencies. Returns a dispose function.
148
+
149
+ # License
150
+ MIT
@@ -0,0 +1,4 @@
1
+ import { Signal } from "./avosignals";
2
+ export declare function singalToJSON(value: Signal<any>): {
3
+ [key: string]: any;
4
+ };
@@ -0,0 +1,31 @@
1
+ import { Signal } from "./avosignals";
2
+ //TODO - function which returns JSON with possibly nested Signals resolved
3
+ export function singalToJSON(value) {
4
+ const result = {};
5
+ const val = value.get();
6
+ if (Array.isArray(val)) {
7
+ return val.map(item => {
8
+ if (item instanceof Signal) {
9
+ return singalToJSON(item);
10
+ }
11
+ else {
12
+ return item;
13
+ }
14
+ });
15
+ }
16
+ else if (typeof val === 'object' && val !== null) {
17
+ for (const key in val) {
18
+ const item = val[key];
19
+ if (item instanceof Signal) {
20
+ result[key] = singalToJSON(item);
21
+ }
22
+ else {
23
+ result[key] = item;
24
+ }
25
+ }
26
+ return result;
27
+ }
28
+ else {
29
+ return val;
30
+ }
31
+ }
@@ -0,0 +1,53 @@
1
+ import type { LitElement } from "lit";
2
+ type Job = () => void;
3
+ type Unsubscribe = () => void;
4
+ interface Debuggable {
5
+ _debugParent?: Watcher;
6
+ }
7
+ interface Watcher extends Debuggable {
8
+ track(signal: Observable<unknown>): void;
9
+ }
10
+ declare abstract class Observable<T> {
11
+ #private;
12
+ name?: string;
13
+ constructor(initial: T, name?: string);
14
+ protected _get(): T;
15
+ protected _set(next: T): boolean;
16
+ subscribe(fn: Job, weak?: boolean): Unsubscribe;
17
+ _notify(): void;
18
+ get id(): number;
19
+ get previousValue(): T | undefined;
20
+ toString(): string;
21
+ }
22
+ export declare class Signal<T> extends Observable<T> {
23
+ #private;
24
+ constructor(initial: T, name?: string);
25
+ static get activeConsumer(): Watcher;
26
+ static push(consumer: Watcher): void;
27
+ static pop(): void;
28
+ get(): T;
29
+ set(value: T): void;
30
+ update(fn: (current: T) => T): void;
31
+ }
32
+ export declare class Computed<T> extends Observable<T> implements Watcher {
33
+ #private;
34
+ _debugParent: Watcher | undefined;
35
+ constructor(fn: () => T, name?: string, options?: {
36
+ weak?: boolean;
37
+ });
38
+ track(signal: Observable<unknown>): void;
39
+ dispose(): void;
40
+ get(): T;
41
+ subscribe(fn: Job, weak?: boolean): Unsubscribe;
42
+ }
43
+ export declare function effect(fn: () => (void | (() => void)), name?: string): () => void;
44
+ export declare class SignalWatcher implements Watcher {
45
+ #private;
46
+ _debugParent: Watcher | undefined;
47
+ constructor(host: LitElement);
48
+ track(signal: Observable<unknown>): void;
49
+ hostUpdate(): void;
50
+ hostUpdated(): void;
51
+ hostDisconnected(): void;
52
+ }
53
+ export {};
@@ -0,0 +1,232 @@
1
+ let __DEV__ = true; //TODO - add logic to detect debug query param
2
+ function safe_not_equal(a, b) {
3
+ return a != a
4
+ ? b == b
5
+ : a !== b || ((a && typeof a === 'object') || typeof a === 'function');
6
+ }
7
+ function isWeakRef(value) {
8
+ return value && typeof value.deref === 'function';
9
+ }
10
+ class Observable {
11
+ static #nextId = 0;
12
+ #value;
13
+ #previousValue;
14
+ #subs = new Set();
15
+ #id = Observable.#nextId++;
16
+ name;
17
+ constructor(initial, name) {
18
+ this.#value = initial;
19
+ this.#previousValue = initial;
20
+ this.name = name;
21
+ }
22
+ _get() {
23
+ if (Signal.activeConsumer) {
24
+ Signal.activeConsumer.track(this);
25
+ }
26
+ return this.#value;
27
+ }
28
+ _set(next) {
29
+ if (!safe_not_equal(next, this.#value))
30
+ return false;
31
+ this.#previousValue = this.#value;
32
+ this.#value = next;
33
+ this._notify();
34
+ return true;
35
+ }
36
+ subscribe(fn, weak = false) {
37
+ let entry = fn;
38
+ if (weak) {
39
+ entry = new WeakRef(fn);
40
+ }
41
+ this.#subs.add(entry);
42
+ return () => this.#subs.delete(entry);
43
+ }
44
+ _notify() {
45
+ const subs = [...this.#subs];
46
+ for (const entry of subs) {
47
+ let job;
48
+ if (isWeakRef(entry)) {
49
+ job = entry.deref();
50
+ // If the object is garbage collected, remove the dead link
51
+ if (!job) {
52
+ this.#subs.delete(entry);
53
+ continue;
54
+ }
55
+ }
56
+ else {
57
+ job = entry;
58
+ }
59
+ job();
60
+ }
61
+ }
62
+ get id() {
63
+ return this.#id;
64
+ }
65
+ get previousValue() {
66
+ return this.#previousValue;
67
+ }
68
+ toString() {
69
+ return this.name
70
+ ? `${this.constructor.name}(${this.name})`
71
+ : `${this.constructor.name}#${this.id}`;
72
+ }
73
+ }
74
+ export class Signal extends Observable {
75
+ static #stack = [];
76
+ constructor(initial, name) {
77
+ super(initial, name);
78
+ }
79
+ static get activeConsumer() {
80
+ return this.#stack[this.#stack.length - 1];
81
+ }
82
+ static push(consumer) {
83
+ if (__DEV__) {
84
+ consumer._debugParent = this.activeConsumer;
85
+ }
86
+ this.#stack.push(consumer);
87
+ }
88
+ static pop() {
89
+ if (__DEV__ && !this.#stack.length) {
90
+ throw new Error('Signal.pop() without matching push()');
91
+ }
92
+ this.#stack.pop();
93
+ }
94
+ get() {
95
+ return this._get();
96
+ }
97
+ set(value) {
98
+ if (__DEV__ && Signal.activeConsumer) {
99
+ throw new Error(`Signal write during reactive evaluation:\n` +
100
+ ` writing ${this}\n` +
101
+ ` while computing ${Signal.activeConsumer}`);
102
+ }
103
+ this._set(value);
104
+ }
105
+ update(fn) {
106
+ this.set(fn(this.get()));
107
+ }
108
+ }
109
+ export class Computed extends Observable {
110
+ #fn;
111
+ #dirty = true;
112
+ #computing = false;
113
+ #deps = new Map();
114
+ _debugParent;
115
+ #weak;
116
+ // 1. Create a stable, bound reference to the notification logic
117
+ #notifyCallback = () => {
118
+ if (this.#dirty)
119
+ return;
120
+ this.#dirty = true;
121
+ this._notify();
122
+ };
123
+ constructor(fn, name, options) {
124
+ super(undefined, name);
125
+ this.#fn = fn;
126
+ // Default to TRUE (Weak) for standard computeds to prevent leaks
127
+ this.#weak = options?.weak ?? true;
128
+ }
129
+ track(signal) {
130
+ if (this.#deps.has(signal))
131
+ return;
132
+ // 2. Subscribe using the STABLE callback and WEAK flag
133
+ const unsub = signal.subscribe(this.#notifyCallback, this.#weak);
134
+ this.#deps.set(signal, unsub);
135
+ }
136
+ dispose() {
137
+ this.#cleanup();
138
+ }
139
+ #cleanup() {
140
+ for (const unsub of this.#deps.values())
141
+ unsub();
142
+ this.#deps.clear();
143
+ }
144
+ get() {
145
+ if (Signal.activeConsumer) {
146
+ Signal.activeConsumer.track(this);
147
+ }
148
+ if (!this.#dirty)
149
+ return this._get();
150
+ if (this.#computing) {
151
+ throw new Error(`Cycle detected in ${this}\n` +
152
+ `↳ while computing ${this._debugParent ?? 'root'}`);
153
+ }
154
+ this.#computing = true;
155
+ this.#cleanup();
156
+ Signal.push(this);
157
+ try {
158
+ const value = this.#fn();
159
+ this._set(value);
160
+ }
161
+ finally {
162
+ Signal.pop();
163
+ this.#dirty = false;
164
+ this.#computing = false;
165
+ }
166
+ return this._get();
167
+ }
168
+ subscribe(fn, weak = false) {
169
+ const unsub = super.subscribe(fn, weak);
170
+ //ensure we don't affect tracking
171
+ if (!Signal.activeConsumer)
172
+ this.get();
173
+ return unsub;
174
+ }
175
+ }
176
+ export function effect(fn, name) {
177
+ let cleanup;
178
+ const computer = new Computed(() => {
179
+ if (typeof cleanup === 'function')
180
+ cleanup();
181
+ cleanup = fn();
182
+ }, name, { weak: false });
183
+ const unsub = computer.subscribe(() => {
184
+ computer.get();
185
+ });
186
+ computer.get();
187
+ return () => {
188
+ unsub();
189
+ computer.dispose();
190
+ if (typeof cleanup === 'function')
191
+ cleanup();
192
+ };
193
+ }
194
+ // Lit
195
+ export class SignalWatcher {
196
+ #host;
197
+ #deps = new Map();
198
+ _debugParent;
199
+ #active = false;
200
+ constructor(host) {
201
+ this.#host = host;
202
+ host.addController(this);
203
+ }
204
+ track(signal) {
205
+ if (this.#deps.has(signal))
206
+ return;
207
+ this.#deps.set(signal, signal.subscribe(() => this.#host.requestUpdate()));
208
+ }
209
+ hostUpdate() {
210
+ for (const unsub of this.#deps.values())
211
+ unsub();
212
+ this.#deps.clear();
213
+ Signal.push(this);
214
+ this.#active = true;
215
+ }
216
+ hostUpdated() {
217
+ if (this.#active) {
218
+ Signal.pop();
219
+ this.#active = false;
220
+ }
221
+ }
222
+ hostDisconnected() {
223
+ for (const unsub of this.#deps.values())
224
+ unsub();
225
+ this.#deps.clear();
226
+ // optional safety if the component disconnects mid-render
227
+ if (this.#active) {
228
+ Signal.pop();
229
+ this.#active = false;
230
+ }
231
+ }
232
+ }
@@ -0,0 +1,8 @@
1
+ import { LitElement } from "lit";
2
+ import { Signal, SignalWatcher } from "./avosignals";
3
+ export declare class SignalWatch extends LitElement {
4
+ watcher: SignalWatcher;
5
+ accessor signals: Signal<unknown>[];
6
+ _subscribers: (() => void)[];
7
+ render(): import("lit-html").TemplateResult<1>;
8
+ }
@@ -0,0 +1,23 @@
1
+ import { LitElement, html } from "lit";
2
+ import { SignalWatcher } from "./avosignals";
3
+ import { customElement, property } from "lit/decorators.js";
4
+ import { singalToJSON } from "./avosignals-util";
5
+ @customElement("signal-watch")
6
+ export class SignalWatch extends LitElement {
7
+ watcher = new SignalWatcher(this);
8
+ @property()
9
+ accessor signals = [];
10
+ _subscribers = [];
11
+ render() {
12
+ // JSON viewer is included in storybook-head
13
+ // also see https://github.com/alenaksu/json-viewer
14
+ return html `
15
+ ${this.signals.map(signal => html `
16
+ <div>
17
+ <pre>Signal [${signal.name || 'unnamed_' + signal.id}]</pre>
18
+ <json-viewer .data=${singalToJSON(signal)}></json-viewer>
19
+ </div>
20
+ `)}
21
+ `;
22
+ }
23
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "avosignals",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight signaling library for web components and modern web applications with Lit integration.",
5
+ "keywords": [
6
+ "signals",
7
+ "events",
8
+ "typescript",
9
+ "observer",
10
+ "reactive"
11
+ ],
12
+ "type": "module",
13
+ "main": "dist/avosignals.js",
14
+ "types": "dist/avosignals.d.ts",
15
+ "files": [
16
+ "dist", "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "author": "Anatoli Radulov",
23
+ "devDependencies": {
24
+ "typescript": "^5.3.3",
25
+ "vitest": "^1.4.0",
26
+ "@vitest/coverage-v8": "^1.4.0",
27
+ "lit": "3.3.2"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/anatolipr/avos.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/anatolipr/avos/issues"
35
+ },
36
+ "license": "MIT"
37
+ }