@hoosk/minisignals 1.1.0 → 1.2.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/README.md CHANGED
@@ -18,8 +18,9 @@ import { signal, effect, computed, batch, untracked } from '@hoosk/minisignals';
18
18
  const count = signal(0);
19
19
  const double = computed(() => count.value * 2);
20
20
 
21
- effect(() => {
21
+ const stop = effect(() => {
22
22
  console.log(`Count: ${count.value}, Double: ${double.value}`);
23
+ return () => console.log('cleanup'); // runs before each re-run and on stop()
23
24
  });
24
25
 
25
26
  count.value = 1; // Logs: Count: 1, Double: 2
@@ -29,11 +30,35 @@ batch(() => {
29
30
  count.value = 10;
30
31
  count.value = 20;
31
32
  }); // Logs once: Count: 20, Double: 40
33
+
34
+ stop();
35
+ ```
36
+
37
+ ### `peek()` and cleanup
38
+
39
+ `Signal` and `Computed` expose `.peek()` to read the current value without subscribing the active effect/computed:
40
+
41
+ ```typescript
42
+ effect(() => {
43
+ count.value; // tracked
44
+ double.peek(); // not tracked
45
+ });
32
46
  ```
33
47
 
48
+ `effect()` callbacks may return a cleanup function that runs before every re-execution and when the effect is stopped.
49
+
50
+ ## Reactivity model
51
+
52
+ Writes are processed in two phases:
53
+
54
+ 1. **Push**: every affected `computed` is marked dirty synchronously.
55
+ 2. **Pull**: queued effects refresh derived values and run once with consistent data.
56
+
57
+ This makes diamond graphs glitch-free, keeps `computed` reads inside `batch()` fresh, and enables early bailout: if a recomputed value is unchanged, downstream effects are skipped.
58
+
34
59
  ## Performance
35
60
 
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.
61
+ The core is built on a linked-list dependency graph with O(1) dependency lookup (`currentLink` pointer), push-pull invalidation (glitch-free diamonds + lazy computed evaluation with version-based early bailout), and class-based signal/computed instances for V8 monomorphization.
37
62
 
38
63
  Benchmark results on Node.js (warmup 150 ms, sample 350 ms):
39
64
 
@@ -41,26 +66,26 @@ Benchmark results on Node.js (warmup 150 ms, sample 350 ms):
41
66
  ── Signal ────────────────────────────────────────────────────
42
67
  scenario ops/s time/op
43
68
  ─────────────────────────────────────────────────────────────
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
69
+ signal-read 533.34M ops/s 1.87 ns/op
70
+ signal-write-no-subscribers 407.35M ops/s 2.45 ns/op
71
+ signal-write-same-value 843.42M ops/s 1.19 ns/op
47
72
 
48
73
  ── Effect ────────────────────────────────────────────────────
49
74
  scenario ops/s time/op
50
75
  ─────────────────────────────────────────────────────────────
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
76
+ effect-fanout-write 18.98K ops/s 52.70 µs/op
77
+ effect-create-dispose 8.94M ops/s 111.83 ns/op
78
+ effect-dynamic-deps 7.34M ops/s 136.20 ns/op
54
79
 
55
80
  ── Computed ──────────────────────────────────────────────────
56
81
  scenario ops/s time/op
57
82
  ─────────────────────────────────────────────────────────────
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
83
+ computed-chain 115.71K ops/s 8.64 µs/op
84
+ computed-cached-read 344.12M ops/s 2.91 ns/op
85
+ computed-batched-writes-no-read 469.72K ops/s 2.13 µs/op
86
+ computed-batched-writes-then-read 525.06K ops/s 1.90 µs/op
62
87
  ```
63
88
 
64
89
  Run `npm run bench` to reproduce locally.
65
90
 
66
- For full documentation and advanced features, please refer to the [main repository README](../../README.md).
91
+ For full documentation and advanced features, please refer to the [main repository README](https://github.com/Hoosk/minisignals#readme).
@@ -1,4 +1,4 @@
1
- import { ReadonlySignal } from './signal.js';
1
+ import type { ReadonlySignal } from './signal.js';
2
2
  export interface Computed<T> extends ReadonlySignal<T> {
3
3
  /**
4
4
  * Stops the computed signal from observing dependencies.
package/dist/computed.js CHANGED
@@ -1,4 +1,83 @@
1
- import { cleanupSubscriber, createDependencySource, getActiveSubscriber, scheduleSource, trackDependency, trackSubscriber, } from './effect.js';
1
+ import { _activeSubscriber, cleanupSubscriber, createDependencySource, invalidateSource, trackDependency, trackSubscriber, untracked, } from './effect.js';
2
+ // Same rationale as SignalImpl: a single prototype keeps V8 happy and lets the
3
+ // getter inline the active-subscriber null check on the hot read path.
4
+ // Implementing Subscriber directly avoids allocating a separate observer node.
5
+ class ComputedImpl {
6
+ /** @internal */ _source;
7
+ depsHead = null;
8
+ depsTail = null;
9
+ trackId = 0;
10
+ flags = 0;
11
+ initialized = false;
12
+ isComputed = true;
13
+ pendingNext = null;
14
+ _fn;
15
+ _cachedValue;
16
+ _dirty = true;
17
+ _disposed = false;
18
+ constructor(fn) {
19
+ this._fn = fn;
20
+ const source = createDependencySource();
21
+ this._source = source;
22
+ source.refresh = () => {
23
+ if (!this._disposed && (!this.initialized || this._dirty)) {
24
+ this._refresh();
25
+ }
26
+ };
27
+ }
28
+ notify() { }
29
+ invalidate() {
30
+ if (this._disposed || this._dirty)
31
+ return;
32
+ this._dirty = true;
33
+ // Keep pushing downstream immediately so every computed in the graph
34
+ // is invalidated before any effect runs (glitch-free diamonds).
35
+ // Skip the walk entirely when nobody observes this computed.
36
+ if (this._source.subsHead !== null)
37
+ invalidateSource(this._source);
38
+ }
39
+ get value() {
40
+ if (this._disposed) {
41
+ if (!this.initialized) {
42
+ // Freeze the first read after disposal without subscribing to the
43
+ // signals read inside fn.
44
+ this._cachedValue = untracked(this._fn);
45
+ this.initialized = true;
46
+ this._dirty = false;
47
+ }
48
+ if (_activeSubscriber !== null)
49
+ trackDependency(this._source);
50
+ return this._cachedValue;
51
+ }
52
+ if (!this.initialized || this._dirty)
53
+ this._refresh();
54
+ if (_activeSubscriber !== null)
55
+ trackDependency(this._source);
56
+ return this._cachedValue;
57
+ }
58
+ peek() {
59
+ return untracked(() => this.value);
60
+ }
61
+ dispose() {
62
+ if (this._disposed)
63
+ return;
64
+ this._disposed = true;
65
+ this._dirty = false;
66
+ cleanupSubscriber(this);
67
+ }
68
+ _refresh() {
69
+ const previousValue = this._cachedValue;
70
+ const wasInitialized = this.initialized;
71
+ this._cachedValue = trackSubscriber(this, this._fn);
72
+ this.initialized = true;
73
+ this._dirty = false;
74
+ // Early bailout: equal derived values do not advance the version, so
75
+ // downstream subscribers can detect that nothing actually changed.
76
+ if (!wasInitialized || this._cachedValue !== previousValue) {
77
+ this._source.version++;
78
+ }
79
+ }
80
+ }
2
81
  /**
3
82
  * Creates a computed signal that derives its value from other signals.
4
83
  * It only calculates and subscribes when its value is read for the first time.
@@ -7,53 +86,5 @@ import { cleanupSubscriber, createDependencySource, getActiveSubscriber, schedul
7
86
  * @returns A read-only signal with a dispose method.
8
87
  */
9
88
  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
- pending: false,
22
- };
23
- let initialized = false;
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.pending = false;
33
- cachedValue = trackSubscriber(observer, fn);
34
- initialized = true;
35
- dirty = false;
36
- }
37
- return {
38
- get value() {
39
- if (getActiveSubscriber())
40
- trackDependency(source);
41
- if (isDisposed) {
42
- if (!initialized) {
43
- cachedValue = fn();
44
- initialized = true;
45
- dirty = false;
46
- }
47
- return cachedValue;
48
- }
49
- if (!initialized || dirty)
50
- refreshValue();
51
- return cachedValue;
52
- },
53
- dispose() {
54
- isDisposed = true;
55
- dirty = false;
56
- cleanupSubscriber(observer);
57
- },
58
- };
89
+ return new ComputedImpl(fn);
59
90
  }
package/dist/effect.d.ts CHANGED
@@ -1,11 +1,19 @@
1
+ export declare const PENDING: number;
2
+ export declare const RUNNING: number;
3
+ export declare const NEEDS_RERUN: number;
4
+ export declare const DISPOSED: number;
1
5
  export interface DependencySource {
2
6
  subsHead: DependencyLink | null;
3
7
  currentLink: DependencyLink | null;
8
+ version: number;
9
+ refresh: (() => void) | null;
10
+ computedCount: number;
4
11
  }
5
- interface DependencyLink {
12
+ export interface DependencyLink {
6
13
  source: DependencySource;
7
14
  subscriber: Subscriber;
8
15
  trackId: number;
16
+ seenVersion: number;
9
17
  rollbackCurrentLink: DependencyLink | null;
10
18
  nextInSource: DependencyLink | null;
11
19
  prevInSource: DependencyLink | null;
@@ -14,10 +22,14 @@ interface DependencyLink {
14
22
  }
15
23
  export interface Subscriber {
16
24
  notify: () => void;
25
+ invalidate: () => void;
17
26
  depsHead: DependencyLink | null;
18
27
  depsTail: DependencyLink | null;
19
28
  trackId: number;
20
- pending: boolean;
29
+ flags: number;
30
+ initialized: boolean;
31
+ isComputed: boolean;
32
+ pendingNext: Subscriber | null;
21
33
  }
22
34
  declare let _activeSubscriber: Subscriber | null;
23
35
  export { _activeSubscriber };
@@ -26,12 +38,17 @@ export declare function getActiveSubscriber(): Subscriber | null;
26
38
  export declare function trackDependency(source: DependencySource): void;
27
39
  export declare function trackSubscriber<T>(subscriber: Subscriber, fn: () => T): T;
28
40
  export declare function cleanupSubscriber(subscriber: Subscriber): void;
41
+ export declare function invalidateSource(source: DependencySource): void;
29
42
  export declare function scheduleSource(source: DependencySource): void;
30
43
  export declare function batch<T>(fn: () => T): T;
31
44
  export declare function untracked<T>(fn: () => T): T;
32
45
  /**
33
46
  * Creates an effect that runs immediately and tracks dependencies.
47
+ *
48
+ * The callback may return a cleanup function. It is invoked before every
49
+ * re-run and when the effect is stopped.
50
+ *
34
51
  * @param fn The function to execute.
35
52
  * @returns An unsubscribe function.
36
53
  */
37
- export declare function effect(fn: () => void): () => void;
54
+ export declare function effect(fn: () => void | (() => void)): () => void;
package/dist/effect.js CHANGED
@@ -1,10 +1,14 @@
1
- // ─── Dependency graph types ───────────────────────────────────────────────────
1
+ // ─── Subscriber flags ───────────────────────────────────────────────────────
2
+ export const PENDING = 1 << 0; // queued in the notification scheduler
3
+ export const RUNNING = 1 << 1; // effect is currently executing
4
+ export const NEEDS_RERUN = 1 << 2; // dependency changed while RUNNING
5
+ export const DISPOSED = 1 << 3; // subscriber was cleaned up and must not run again
2
6
  let _activeSubscriber = null;
3
- // Exported so signal.ts can do an inline null-check in the getter hot path,
4
- // avoiding a function call when there is no active subscriber.
7
+ // Exported so signal.ts/computed.ts can do an inline null-check in the getter
8
+ // hot path, avoiding a function call when there is no active subscriber.
5
9
  export { _activeSubscriber };
6
10
  export function createDependencySource() {
7
- return { subsHead: null, currentLink: null };
11
+ return { subsHead: null, currentLink: null, version: 0, refresh: null, computedCount: 0 };
8
12
  }
9
13
  export function getActiveSubscriber() {
10
14
  return _activeSubscriber;
@@ -24,8 +28,9 @@ function prepareSources(subscriber) {
24
28
  function linkDependency(source, subscriber) {
25
29
  const existing = source.currentLink;
26
30
  if (existing !== null && existing.subscriber === subscriber) {
27
- // Stable dep: just mark as seen in this run.
31
+ // Stable dep: mark as seen in this run and record the observed version.
28
32
  existing.trackId = subscriber.trackId;
33
+ existing.seenVersion = source.version;
29
34
  return;
30
35
  }
31
36
  // New dep: create a link and install it. Save whatever was in currentLink
@@ -34,6 +39,7 @@ function linkDependency(source, subscriber) {
34
39
  source,
35
40
  subscriber,
36
41
  trackId: subscriber.trackId,
42
+ seenVersion: source.version,
37
43
  rollbackCurrentLink: existing,
38
44
  nextInSource: source.subsHead,
39
45
  prevInSource: null,
@@ -43,6 +49,8 @@ function linkDependency(source, subscriber) {
43
49
  if (source.subsHead)
44
50
  source.subsHead.prevInSource = link;
45
51
  source.subsHead = link;
52
+ if (subscriber.isComputed)
53
+ source.computedCount++;
46
54
  if (subscriber.depsTail) {
47
55
  subscriber.depsTail.nextInSubscriber = link;
48
56
  }
@@ -54,6 +62,8 @@ function linkDependency(source, subscriber) {
54
62
  }
55
63
  function unlinkDependency(link) {
56
64
  const { source, subscriber } = link;
65
+ if (subscriber.isComputed)
66
+ source.computedCount--;
57
67
  if (link.prevInSource) {
58
68
  link.prevInSource.nextInSource = link.nextInSource;
59
69
  }
@@ -120,28 +130,107 @@ export function cleanupSubscriber(subscriber) {
120
130
  unlinkDependency(link);
121
131
  link = next;
122
132
  }
123
- subscriber.pending = false;
133
+ subscriber.depsHead = null;
134
+ subscriber.depsTail = null;
135
+ subscriber.flags = DISPOSED;
124
136
  }
125
- // ─── Batch scheduling ────────────────────────────────────────────────────────
137
+ // ─── Push phase ──────────────────────────────────────────────────────────────
138
+ // Walks the subscriber list and lets each node decide how to invalidate:
139
+ // computeds mark themselves dirty and keep propagating synchronously, while
140
+ // effects only queue themselves. No effect runs during this phase, which is
141
+ // what makes diamond graphs glitch-free.
142
+ export function invalidateSource(source) {
143
+ let link = source.subsHead;
144
+ while (link) {
145
+ const next = link.nextInSource;
146
+ link.subscriber.invalidate();
147
+ link = next;
148
+ }
149
+ }
150
+ // ─── Pull phase / scheduling ─────────────────────────────────────────────────
126
151
  let _batchDepth = 0;
127
- const _pendingNotifications = [];
128
- function queueSubscriber(subscriber) {
129
- if (_batchDepth > 0) {
130
- if (!subscriber.pending) {
131
- subscriber.pending = true;
132
- _pendingNotifications.push(subscriber);
133
- }
152
+ let _flushDepth = 0;
153
+ let _pendingHead = null;
154
+ let _pendingTail = null;
155
+ function enqueueSubscriber(subscriber) {
156
+ subscriber.flags |= PENDING;
157
+ subscriber.pendingNext = null;
158
+ if (_pendingTail === null) {
159
+ _pendingHead = subscriber;
134
160
  }
135
161
  else {
136
- subscriber.notify();
162
+ _pendingTail.pendingNext = subscriber;
163
+ }
164
+ _pendingTail = subscriber;
165
+ }
166
+ function clearPendingQueue() {
167
+ let subscriber = _pendingHead;
168
+ while (subscriber !== null) {
169
+ const next = subscriber.pendingNext;
170
+ subscriber.flags &= ~PENDING;
171
+ subscriber.pendingNext = null;
172
+ subscriber = next;
173
+ }
174
+ _pendingHead = null;
175
+ _pendingTail = null;
176
+ }
177
+ function flushPendingNotifications() {
178
+ if (_flushDepth > 0)
179
+ return;
180
+ _flushDepth++;
181
+ let failed = false;
182
+ try {
183
+ while (_pendingHead !== null) {
184
+ const subscriber = _pendingHead;
185
+ _pendingHead = subscriber.pendingNext;
186
+ subscriber.pendingNext = null;
187
+ if (_pendingHead === null)
188
+ _pendingTail = null;
189
+ if (!(subscriber.flags & PENDING))
190
+ continue;
191
+ subscriber.flags &= ~PENDING;
192
+ subscriber.notify();
193
+ }
194
+ }
195
+ catch (error) {
196
+ failed = true;
197
+ throw error;
198
+ }
199
+ finally {
200
+ // On failure, do not leave subscribers permanently stuck with PENDING set.
201
+ if (failed)
202
+ clearPendingQueue();
203
+ _flushDepth--;
137
204
  }
138
205
  }
206
+ // Every signal write is an implicit batch: versions are bumped, invalidations
207
+ // are pushed through the whole graph, and only then are effects notified.
139
208
  export function scheduleSource(source) {
140
- let link = source.subsHead;
141
- while (link) {
142
- const next = link.nextInSource;
143
- queueSubscriber(link.subscriber);
144
- link = next;
209
+ source.version++;
210
+ if (_batchDepth !== 0) {
211
+ invalidateSource(source);
212
+ return;
213
+ }
214
+ // Fast path: without computed subscribers there is no derived state that
215
+ // could be observed mid-write, so effects can run right away (glitches are
216
+ // impossible).
217
+ if (source.computedCount === 0) {
218
+ let link = source.subsHead;
219
+ while (link) {
220
+ const next = link.nextInSource;
221
+ link.subscriber.notify();
222
+ link = next;
223
+ }
224
+ return;
225
+ }
226
+ _batchDepth = 1;
227
+ try {
228
+ invalidateSource(source);
229
+ }
230
+ finally {
231
+ _batchDepth = 0;
232
+ if (_pendingHead !== null)
233
+ flushPendingNotifications();
145
234
  }
146
235
  }
147
236
  export function batch(fn) {
@@ -151,14 +240,8 @@ export function batch(fn) {
151
240
  }
152
241
  finally {
153
242
  _batchDepth--;
154
- if (_batchDepth === 0) {
155
- const pending = _pendingNotifications.splice(0);
156
- for (const sub of pending) {
157
- if (!sub.pending)
158
- continue;
159
- sub.pending = false;
160
- sub.notify();
161
- }
243
+ if (_batchDepth === 0 && _pendingHead !== null) {
244
+ flushPendingNotifications();
162
245
  }
163
246
  }
164
247
  }
@@ -175,39 +258,120 @@ export function untracked(fn) {
175
258
  }
176
259
  // ─── Effect ──────────────────────────────────────────────────────────────────
177
260
  const MAX_EFFECT_ITERATIONS = 100;
261
+ function dependenciesChanged(subscriber) {
262
+ if (!subscriber.initialized)
263
+ return true;
264
+ let link = subscriber.depsHead;
265
+ while (link) {
266
+ const source = link.source;
267
+ // Pull derived values before comparing: a dirty computed may still end up
268
+ // producing the same value, which lets us skip the re-run entirely.
269
+ if (source.refresh !== null)
270
+ source.refresh();
271
+ if (source.version !== link.seenVersion)
272
+ return true;
273
+ link = link.nextInSubscriber;
274
+ }
275
+ return false;
276
+ }
178
277
  /**
179
278
  * Creates an effect that runs immediately and tracks dependencies.
279
+ *
280
+ * The callback may return a cleanup function. It is invoked before every
281
+ * re-run and when the effect is stopped.
282
+ *
180
283
  * @param fn The function to execute.
181
284
  * @returns An unsubscribe function.
182
285
  */
183
286
  export function effect(fn) {
184
- const effectObj = {};
185
- effectObj.notify = () => {
186
- if (effectObj.isRunning) {
187
- effectObj.needsRerun = true;
287
+ const instance = new EffectImpl(fn);
288
+ instance.notify();
289
+ return () => instance.stop();
290
+ }
291
+ // Class-based so every effect shares the same hidden class and prototype
292
+ // methods. This keeps subscription lists monomorphic for V8 and avoids
293
+ // allocating a pair of closures per effect instance.
294
+ class EffectImpl {
295
+ depsHead = null;
296
+ depsTail = null;
297
+ trackId = 0;
298
+ flags = 0;
299
+ initialized = false;
300
+ isComputed = false;
301
+ pendingNext = null;
302
+ _fn;
303
+ _cleanup = null;
304
+ constructor(fn) {
305
+ this._fn = fn;
306
+ }
307
+ notify() {
308
+ if (this.flags & DISPOSED)
309
+ return;
310
+ if (this.flags & RUNNING) {
311
+ this.flags |= NEEDS_RERUN;
312
+ return;
313
+ }
314
+ if (!dependenciesChanged(this))
315
+ return;
316
+ this._run();
317
+ }
318
+ invalidate() {
319
+ if (this.flags & DISPOSED)
188
320
  return;
321
+ if (_batchDepth !== 0) {
322
+ if (!(this.flags & PENDING))
323
+ enqueueSubscriber(this);
189
324
  }
190
- effectObj.isRunning = true;
325
+ else {
326
+ this.notify();
327
+ }
328
+ }
329
+ stop() {
330
+ if (this.flags & DISPOSED)
331
+ return;
332
+ try {
333
+ if (this._cleanup !== null) {
334
+ const previousCleanup = this._cleanup;
335
+ this._cleanup = null;
336
+ previousCleanup();
337
+ }
338
+ }
339
+ finally {
340
+ cleanupSubscriber(this);
341
+ }
342
+ }
343
+ _run() {
344
+ this.flags |= RUNNING;
191
345
  let iterations = 0;
192
346
  try {
193
347
  do {
194
348
  if (++iterations > MAX_EFFECT_ITERATIONS) {
195
349
  throw new Error('Circular dependency detected: effect exceeded maximum re-run limit');
196
350
  }
197
- effectObj.needsRerun = false;
198
- trackSubscriber(effectObj, fn);
199
- } while (effectObj.needsRerun);
351
+ this.flags &= ~NEEDS_RERUN;
352
+ if (this._cleanup !== null) {
353
+ const previousCleanup = this._cleanup;
354
+ this._cleanup = null;
355
+ previousCleanup();
356
+ }
357
+ const result = trackSubscriber(this, this._fn);
358
+ this._cleanup = typeof result === 'function' ? result : null;
359
+ this.initialized = true;
360
+ if (this.flags & DISPOSED) {
361
+ if (this._cleanup !== null) {
362
+ const finalCleanup = this._cleanup;
363
+ this._cleanup = null;
364
+ finalCleanup();
365
+ }
366
+ // Dependencies read after stop() was called during this run were
367
+ // linked again; remove them so a disposed effect holds no references.
368
+ cleanupSubscriber(this);
369
+ break;
370
+ }
371
+ } while (this.flags & NEEDS_RERUN);
200
372
  }
201
373
  finally {
202
- effectObj.isRunning = false;
374
+ this.flags &= ~RUNNING;
203
375
  }
204
- };
205
- effectObj.depsHead = null;
206
- effectObj.depsTail = null;
207
- effectObj.trackId = 0;
208
- effectObj.pending = false;
209
- effectObj.isRunning = false;
210
- effectObj.needsRerun = false;
211
- effectObj.notify();
212
- return () => cleanupSubscriber(effectObj);
376
+ }
213
377
  }
package/dist/signal.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export interface ReadonlySignal<T> {
2
2
  readonly value: T;
3
+ /**
4
+ * Reads the current value without subscribing the active effect/computed.
5
+ * Unlike `untracked(() => sig.value)` this avoids allocating a closure.
6
+ */
7
+ peek(): T;
3
8
  }
4
9
  export interface Signal<T> extends ReadonlySignal<T> {
5
10
  value: T;
package/dist/signal.js CHANGED
@@ -19,9 +19,15 @@ class SignalImpl {
19
19
  set value(newValue) {
20
20
  if (this._value !== newValue) {
21
21
  this._value = newValue;
22
- scheduleSource(this._source);
22
+ // Writes with no subscribers are a pure property assignment: skip the
23
+ // scheduling machinery entirely.
24
+ if (this._source.subsHead !== null)
25
+ scheduleSource(this._source);
23
26
  }
24
27
  }
28
+ peek() {
29
+ return this._value;
30
+ }
25
31
  }
26
32
  /**
27
33
  * Creates a reactive signal.
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@hoosk/minisignals",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Minimal agnostic signals library",
5
+ "type": "module",
6
+ "sideEffects": false,
5
7
  "main": "dist/index.js",
6
8
  "module": "dist/index.js",
7
9
  "types": "dist/index.d.ts",
@@ -12,7 +14,6 @@
12
14
  "default": "./dist/index.js"
13
15
  }
14
16
  },
15
- "type": "module",
16
17
  "files": [
17
18
  "dist"
18
19
  ],
@@ -22,7 +23,22 @@
22
23
  "test": "vitest run",
23
24
  "bench": "npm run build && node ./bench/core.bench.mjs"
24
25
  },
25
- "keywords": [],
26
+ "keywords": [
27
+ "signals",
28
+ "reactive",
29
+ "reactivity",
30
+ "state",
31
+ "computed",
32
+ "effect",
33
+ "signal",
34
+ "observable",
35
+ "framework-agnostic"
36
+ ],
26
37
  "author": "Hoosk",
27
- "license": "MIT"
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/Hoosk/minisignals.git",
42
+ "directory": "packages/core"
43
+ }
28
44
  }