@hoosk/minisignals 1.0.7 → 1.1.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
@@ -31,4 +31,36 @@ batch(() => {
31
31
  }); // Logs once: Count: 20, Double: 40
32
32
  ```
33
33
 
34
- For full documentation and advanced features, please refer to the [main repository README](../../README.md).
34
+ ## Performance
35
+
36
+ The core is built on a linked-list dependency graph with O(1) dependency lookup (`currentLink` pointer), lazy computed evaluation (dirty flag, no eager recomputation), and class-based signal instances for V8 monomorphization.
37
+
38
+ Benchmark results on Node.js (warmup 150 ms, sample 350 ms):
39
+
40
+ ```
41
+ ── Signal ────────────────────────────────────────────────────
42
+ scenario ops/s time/op
43
+ ─────────────────────────────────────────────────────────────
44
+ signal-read 420.38M ops/s 2.38 ns/op
45
+ signal-write-no-subscribers 210.86M ops/s 4.74 ns/op
46
+ signal-write-same-value 760.84M ops/s 1.31 ns/op
47
+
48
+ ── Effect ────────────────────────────────────────────────────
49
+ scenario ops/s time/op
50
+ ─────────────────────────────────────────────────────────────
51
+ effect-fanout-write 17.77K ops/s 56.27 µs/op
52
+ effect-create-dispose 5.86M ops/s 170.66 ns/op
53
+ effect-dynamic-deps 7.08M ops/s 141.15 ns/op
54
+
55
+ ── Computed ──────────────────────────────────────────────────
56
+ scenario ops/s time/op
57
+ ─────────────────────────────────────────────────────────────
58
+ computed-chain 51.07K ops/s 19.58 µs/op
59
+ computed-cached-read 44.59M ops/s 22.42 ns/op
60
+ computed-batched-writes-no-read 742.71K ops/s 1.35 µs/op
61
+ computed-batched-writes-then-read 706.54K ops/s 1.42 µs/op
62
+ ```
63
+
64
+ Run `npm run bench` to reproduce locally.
65
+
66
+ For full documentation and advanced features, please refer to the [main repository README](https://github.com/Hoosk/minisignals#readme).
@@ -1,7 +1,7 @@
1
1
  import { ReadonlySignal } from './signal.js';
2
2
  export interface Computed<T> extends ReadonlySignal<T> {
3
3
  /**
4
- * Stops the internal effect from observing dependencies.
4
+ * Stops the computed signal from observing dependencies.
5
5
  * Call this when the computed signal is no longer needed to prevent memory leaks.
6
6
  */
7
7
  dispose: () => void;
package/dist/computed.js CHANGED
@@ -1,5 +1,4 @@
1
- import { signal } from './signal.js';
2
- import { effect } from './effect.js';
1
+ import { cleanupSubscriber, createDependencySource, getActiveSubscriber, PENDING, scheduleSource, trackDependency, trackSubscriber, } from './effect.js';
3
2
  /**
4
3
  * Creates a computed signal that derives its value from other signals.
5
4
  * It only calculates and subscribes when its value is read for the first time.
@@ -8,39 +7,53 @@ import { effect } from './effect.js';
8
7
  * @returns A read-only signal with a dispose method.
9
8
  */
10
9
  export function computed(fn) {
10
+ const source = createDependencySource();
11
+ const observer = {
12
+ notify() {
13
+ if (isDisposed || dirty)
14
+ return;
15
+ dirty = true;
16
+ scheduleSource(source);
17
+ },
18
+ depsHead: null,
19
+ depsTail: null,
20
+ trackId: 0,
21
+ flags: 0,
22
+ };
11
23
  let initialized = false;
12
- let internalSignal = null;
13
- let unsubscribe = null;
14
24
  let isDisposed = false;
25
+ let dirty = true;
26
+ let cachedValue;
27
+ function refreshValue() {
28
+ // Clear PENDING so a batch flush after an eager read inside the same batch
29
+ // does not re-trigger notify() on an already-fresh computed.
30
+ // We do NOT call cleanupSubscriber here: trackSubscriber increments trackId
31
+ // and pruneStaleDependencies removes only stale links, reusing stable ones.
32
+ observer.flags &= ~PENDING;
33
+ cachedValue = trackSubscriber(observer, fn);
34
+ initialized = true;
35
+ dirty = false;
36
+ }
15
37
  return {
16
38
  get value() {
39
+ if (getActiveSubscriber())
40
+ trackDependency(source);
17
41
  if (isDisposed) {
18
42
  if (!initialized) {
19
- internalSignal = signal(fn());
43
+ cachedValue = fn();
20
44
  initialized = true;
45
+ dirty = false;
21
46
  }
22
- return internalSignal.value;
23
- }
24
- if (!initialized) {
25
- unsubscribe = effect(() => {
26
- const newValue = fn();
27
- if (!initialized) {
28
- internalSignal = signal(newValue);
29
- initialized = true;
30
- }
31
- else {
32
- internalSignal.value = newValue;
33
- }
34
- });
47
+ return cachedValue;
35
48
  }
36
- return internalSignal.value;
49
+ if (!initialized || dirty)
50
+ refreshValue();
51
+ return cachedValue;
37
52
  },
38
53
  dispose() {
39
54
  isDisposed = true;
40
- if (unsubscribe) {
41
- unsubscribe();
42
- unsubscribe = null;
43
- }
44
- }
55
+ dirty = false;
56
+ cleanupSubscriber(observer);
57
+ },
45
58
  };
46
59
  }
package/dist/effect.d.ts CHANGED
@@ -1,13 +1,40 @@
1
- export type SubscriberSet = Set<() => void>;
2
- interface ActiveEffect {
3
- run: () => void;
4
- deps: Set<SubscriberSet>;
5
- isRunning: boolean;
6
- needsRerun: boolean;
1
+ export declare const PENDING: number;
2
+ export declare const RUNNING: number;
3
+ export declare const NEEDS_RERUN: number;
4
+ export interface DependencySource {
5
+ subsHead: DependencyLink | null;
6
+ currentLink: DependencyLink | null;
7
7
  }
8
- export declare function getActiveEffect(): ActiveEffect | null;
9
- export declare function scheduleSubs(subscribers: SubscriberSet): void;
8
+ interface DependencyLink {
9
+ source: DependencySource;
10
+ subscriber: Subscriber;
11
+ trackId: number;
12
+ rollbackCurrentLink: DependencyLink | null;
13
+ nextInSource: DependencyLink | null;
14
+ prevInSource: DependencyLink | null;
15
+ nextInSubscriber: DependencyLink | null;
16
+ prevInSubscriber: DependencyLink | null;
17
+ }
18
+ export interface Subscriber {
19
+ notify: () => void;
20
+ depsHead: DependencyLink | null;
21
+ depsTail: DependencyLink | null;
22
+ trackId: number;
23
+ flags: number;
24
+ }
25
+ declare let _activeSubscriber: Subscriber | null;
26
+ export { _activeSubscriber };
27
+ export declare function createDependencySource(): DependencySource;
28
+ export declare function getActiveSubscriber(): Subscriber | null;
29
+ export declare function trackDependency(source: DependencySource): void;
30
+ export declare function trackSubscriber<T>(subscriber: Subscriber, fn: () => T): T;
31
+ export declare function cleanupSubscriber(subscriber: Subscriber): void;
32
+ export declare function scheduleSource(source: DependencySource): void;
10
33
  export declare function batch<T>(fn: () => T): T;
11
34
  export declare function untracked<T>(fn: () => T): T;
35
+ /**
36
+ * Creates an effect that runs immediately and tracks dependencies.
37
+ * @param fn The function to execute.
38
+ * @returns An unsubscribe function.
39
+ */
12
40
  export declare function effect(fn: () => void): () => void;
13
- export {};
package/dist/effect.js CHANGED
@@ -1,20 +1,150 @@
1
- let _activeEffect = null;
2
- export function getActiveEffect() {
3
- return _activeEffect;
1
+ // ─── Subscriber flags ───────────────────────────────────────────────────────
2
+ export const PENDING = 1 << 0; // queued in _pendingNotifications
3
+ export const RUNNING = 1 << 1; // effect is currently executing
4
+ export const NEEDS_RERUN = 1 << 2; // dependency changed while RUNNING
5
+ let _activeSubscriber = null;
6
+ // Exported so signal.ts can do an inline null-check in the getter hot path,
7
+ // avoiding a function call when there is no active subscriber.
8
+ export { _activeSubscriber };
9
+ export function createDependencySource() {
10
+ return { subsHead: null, currentLink: null };
4
11
  }
5
- // --- Batch scheduling ---
12
+ export function getActiveSubscriber() {
13
+ return _activeSubscriber;
14
+ }
15
+ // ─── Link management ─────────────────────────────────────────────────────────
16
+ // Before running a subscriber, stamp each of its existing dep links onto
17
+ // source.currentLink (saving whatever was there as rollbackCurrentLink).
18
+ // This lets linkDependency find the reusable link with a single pointer read.
19
+ function prepareSources(subscriber) {
20
+ let link = subscriber.depsHead;
21
+ while (link) {
22
+ link.rollbackCurrentLink = link.source.currentLink;
23
+ link.source.currentLink = link;
24
+ link = link.nextInSubscriber;
25
+ }
26
+ }
27
+ function linkDependency(source, subscriber) {
28
+ const existing = source.currentLink;
29
+ if (existing !== null && existing.subscriber === subscriber) {
30
+ // Stable dep: just mark as seen in this run.
31
+ existing.trackId = subscriber.trackId;
32
+ return;
33
+ }
34
+ // New dep: create a link and install it. Save whatever was in currentLink
35
+ // so it can be restored when the tracking pass finishes (rollback chain).
36
+ const link = {
37
+ source,
38
+ subscriber,
39
+ trackId: subscriber.trackId,
40
+ rollbackCurrentLink: existing,
41
+ nextInSource: source.subsHead,
42
+ prevInSource: null,
43
+ nextInSubscriber: null,
44
+ prevInSubscriber: subscriber.depsTail,
45
+ };
46
+ if (source.subsHead)
47
+ source.subsHead.prevInSource = link;
48
+ source.subsHead = link;
49
+ if (subscriber.depsTail) {
50
+ subscriber.depsTail.nextInSubscriber = link;
51
+ }
52
+ else {
53
+ subscriber.depsHead = link;
54
+ }
55
+ subscriber.depsTail = link;
56
+ source.currentLink = link;
57
+ }
58
+ function unlinkDependency(link) {
59
+ const { source, subscriber } = link;
60
+ if (link.prevInSource) {
61
+ link.prevInSource.nextInSource = link.nextInSource;
62
+ }
63
+ else {
64
+ source.subsHead = link.nextInSource;
65
+ }
66
+ if (link.nextInSource)
67
+ link.nextInSource.prevInSource = link.prevInSource;
68
+ if (link.prevInSubscriber) {
69
+ link.prevInSubscriber.nextInSubscriber = link.nextInSubscriber;
70
+ }
71
+ else {
72
+ subscriber.depsHead = link.nextInSubscriber;
73
+ }
74
+ if (link.nextInSubscriber) {
75
+ link.nextInSubscriber.prevInSubscriber = link.prevInSubscriber;
76
+ }
77
+ else {
78
+ subscriber.depsTail = link.prevInSubscriber;
79
+ }
80
+ link.nextInSource = link.prevInSource = null;
81
+ link.nextInSubscriber = link.prevInSubscriber = null;
82
+ }
83
+ // After the fn runs: prune stale links and restore source.currentLink for all
84
+ // links we touched (both kept and removed) so outer contexts are unaffected.
85
+ function pruneStaleDependencies(subscriber) {
86
+ let link = subscriber.depsHead;
87
+ while (link) {
88
+ const next = link.nextInSubscriber;
89
+ // Restore regardless of whether we keep or prune the link.
90
+ link.source.currentLink = link.rollbackCurrentLink;
91
+ link.rollbackCurrentLink = null;
92
+ if (link.trackId !== subscriber.trackId)
93
+ unlinkDependency(link);
94
+ link = next;
95
+ }
96
+ }
97
+ export function trackDependency(source) {
98
+ if (_activeSubscriber)
99
+ linkDependency(source, _activeSubscriber);
100
+ }
101
+ export function trackSubscriber(subscriber, fn) {
102
+ const previous = _activeSubscriber;
103
+ subscriber.trackId++;
104
+ prepareSources(subscriber);
105
+ _activeSubscriber = subscriber;
106
+ try {
107
+ return fn();
108
+ }
109
+ finally {
110
+ _activeSubscriber = previous;
111
+ pruneStaleDependencies(subscriber);
112
+ }
113
+ }
114
+ export function cleanupSubscriber(subscriber) {
115
+ let link = subscriber.depsHead;
116
+ while (link) {
117
+ const next = link.nextInSubscriber;
118
+ // Restore currentLink if it still points to this link.
119
+ if (link.source.currentLink === link) {
120
+ link.source.currentLink = link.rollbackCurrentLink;
121
+ }
122
+ link.rollbackCurrentLink = null;
123
+ unlinkDependency(link);
124
+ link = next;
125
+ }
126
+ subscriber.flags &= ~PENDING;
127
+ }
128
+ // ─── Batch scheduling ────────────────────────────────────────────────────────
6
129
  let _batchDepth = 0;
7
- const _pendingNotifications = new Set();
8
- export function scheduleSubs(subscribers) {
130
+ const _pendingNotifications = [];
131
+ function queueSubscriber(subscriber) {
9
132
  if (_batchDepth > 0) {
10
- for (const sub of subscribers) {
11
- _pendingNotifications.add(sub);
133
+ if (!(subscriber.flags & PENDING)) {
134
+ subscriber.flags |= PENDING;
135
+ _pendingNotifications.push(subscriber);
12
136
  }
13
137
  }
14
138
  else {
15
- for (const sub of [...subscribers]) {
16
- sub();
17
- }
139
+ subscriber.notify();
140
+ }
141
+ }
142
+ export function scheduleSource(source) {
143
+ let link = source.subsHead;
144
+ while (link) {
145
+ const next = link.nextInSource;
146
+ queueSubscriber(link.subscriber);
147
+ link = next;
18
148
  }
19
149
  }
20
150
  export function batch(fn) {
@@ -25,73 +155,60 @@ export function batch(fn) {
25
155
  finally {
26
156
  _batchDepth--;
27
157
  if (_batchDepth === 0) {
28
- const pending = [..._pendingNotifications];
29
- _pendingNotifications.clear();
158
+ const pending = _pendingNotifications.splice(0);
30
159
  for (const sub of pending) {
31
- sub();
160
+ if (!(sub.flags & PENDING))
161
+ continue;
162
+ sub.flags &= ~PENDING;
163
+ sub.notify();
32
164
  }
33
165
  }
34
166
  }
35
167
  }
36
- // --- Untracked read ---
168
+ // ─── Untracked read ───────────────────────────────────────────────────────────
37
169
  export function untracked(fn) {
38
- const previous = _activeEffect;
39
- _activeEffect = null;
170
+ const previous = _activeSubscriber;
171
+ _activeSubscriber = null;
40
172
  try {
41
173
  return fn();
42
174
  }
43
175
  finally {
44
- _activeEffect = previous;
176
+ _activeSubscriber = previous;
45
177
  }
46
178
  }
47
- // --- Effect ---
179
+ // ─── Effect ──────────────────────────────────────────────────────────────────
180
+ const MAX_EFFECT_ITERATIONS = 100;
48
181
  /**
49
182
  * Creates an effect that runs immediately and tracks dependencies.
50
183
  * @param fn The function to execute.
51
184
  * @returns An unsubscribe function.
52
185
  */
53
- const MAX_EFFECT_ITERATIONS = 100;
54
186
  export function effect(fn) {
55
- const effectObj = {
56
- run() {
57
- if (effectObj.isRunning) {
58
- effectObj.needsRerun = true;
59
- return;
60
- }
61
- effectObj.isRunning = true;
62
- let iterations = 0;
63
- try {
64
- do {
65
- if (++iterations > MAX_EFFECT_ITERATIONS) {
66
- throw new Error('Circular dependency detected: effect exceeded maximum re-run limit');
67
- }
68
- effectObj.needsRerun = false;
69
- cleanup(effectObj);
70
- const previousEffect = _activeEffect;
71
- _activeEffect = effectObj;
72
- try {
73
- fn();
74
- }
75
- finally {
76
- _activeEffect = previousEffect;
77
- }
78
- } while (effectObj.needsRerun);
79
- }
80
- finally {
81
- effectObj.isRunning = false;
82
- }
83
- },
84
- deps: new Set(),
85
- isRunning: false,
86
- needsRerun: false,
87
- };
88
- function cleanup(eff) {
89
- for (const depSet of eff.deps) {
90
- depSet.delete(eff.run);
187
+ const effectObj = {};
188
+ effectObj.notify = () => {
189
+ if (effectObj.flags & RUNNING) {
190
+ effectObj.flags |= NEEDS_RERUN;
191
+ return;
91
192
  }
92
- eff.deps.clear();
93
- _pendingNotifications.delete(eff.run);
94
- }
95
- effectObj.run();
96
- return () => cleanup(effectObj);
193
+ effectObj.flags |= RUNNING;
194
+ let iterations = 0;
195
+ try {
196
+ do {
197
+ if (++iterations > MAX_EFFECT_ITERATIONS) {
198
+ throw new Error('Circular dependency detected: effect exceeded maximum re-run limit');
199
+ }
200
+ effectObj.flags &= ~NEEDS_RERUN;
201
+ trackSubscriber(effectObj, fn);
202
+ } while (effectObj.flags & NEEDS_RERUN);
203
+ }
204
+ finally {
205
+ effectObj.flags &= ~RUNNING;
206
+ }
207
+ };
208
+ effectObj.depsHead = null;
209
+ effectObj.depsTail = null;
210
+ effectObj.trackId = 0;
211
+ effectObj.flags = 0;
212
+ effectObj.notify();
213
+ return () => cleanupSubscriber(effectObj);
97
214
  }
package/dist/signal.js CHANGED
@@ -1,26 +1,33 @@
1
- import { getActiveEffect, scheduleSubs } from './effect.js';
1
+ import { _activeSubscriber, createDependencySource, scheduleSource, trackDependency } from './effect.js';
2
+ // Class-based implementation so all signal instances share the same prototype
3
+ // getter/setter. V8 can monomorphize and inline the accessors instead of
4
+ // treating every signal as a unique object with closure-based functions.
5
+ class SignalImpl {
6
+ /** @internal */ _source;
7
+ /** @internal */ _value;
8
+ constructor(initialValue) {
9
+ this._source = createDependencySource();
10
+ this._value = initialValue;
11
+ }
12
+ get value() {
13
+ // Inline null-check to avoid the trackDependency() function call entirely
14
+ // when there is no active subscriber — the common case outside effects.
15
+ if (_activeSubscriber !== null)
16
+ trackDependency(this._source);
17
+ return this._value;
18
+ }
19
+ set value(newValue) {
20
+ if (this._value !== newValue) {
21
+ this._value = newValue;
22
+ scheduleSource(this._source);
23
+ }
24
+ }
25
+ }
2
26
  /**
3
27
  * Creates a reactive signal.
4
28
  * @param initialValue The starting value.
5
29
  * @returns An object with a reactive .value property.
6
30
  */
7
31
  export function signal(initialValue) {
8
- const subscribers = new Set();
9
- let _value = initialValue;
10
- return {
11
- get value() {
12
- const active = getActiveEffect();
13
- if (active) {
14
- subscribers.add(active.run);
15
- active.deps.add(subscribers);
16
- }
17
- return _value;
18
- },
19
- set value(newValue) {
20
- if (_value !== newValue) {
21
- _value = newValue;
22
- scheduleSubs(subscribers);
23
- }
24
- }
25
- };
32
+ return new SignalImpl(initialValue);
26
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoosk/minisignals",
3
- "version": "1.0.7",
3
+ "version": "1.1.1",
4
4
  "description": "Minimal agnostic signals library",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -19,9 +19,10 @@
19
19
  "scripts": {
20
20
  "build": "tsc",
21
21
  "prepare": "npm run build",
22
- "test": "vitest run"
22
+ "test": "vitest run",
23
+ "bench": "npm run build && node ./bench/core.bench.mjs"
23
24
  },
24
- "keywords": [],
25
+ "keywords": ["signals", "reactive", "reactivity", "state", "computed", "effect", "signal", "observable", "framework-agnostic"],
25
26
  "author": "Hoosk",
26
27
  "license": "MIT"
27
28
  }