@hoosk/minisignals 1.1.1 → 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 +37 -12
- package/dist/computed.d.ts +1 -1
- package/dist/computed.js +81 -50
- package/dist/effect.d.ts +16 -2
- package/dist/effect.js +207 -44
- package/dist/signal.d.ts +5 -0
- package/dist/signal.js +7 -1
- package/package.json +20 -4
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
|
|
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,24 +66,24 @@ 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
|
|
45
|
-
signal-write-no-subscribers
|
|
46
|
-
signal-write-same-value
|
|
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
|
|
52
|
-
effect-create-dispose
|
|
53
|
-
effect-dynamic-deps 7.
|
|
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
|
|
59
|
-
computed-cached-read
|
|
60
|
-
computed-batched-writes-no-read
|
|
61
|
-
computed-batched-writes-then-read
|
|
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.
|
package/dist/computed.d.ts
CHANGED
package/dist/computed.js
CHANGED
|
@@ -1,4 +1,83 @@
|
|
|
1
|
-
import { cleanupSubscriber, createDependencySource,
|
|
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, PENDING
|
|
|
7
86
|
* @returns A read-only signal with a dispose method.
|
|
8
87
|
*/
|
|
9
88
|
export function computed(fn) {
|
|
10
|
-
|
|
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
|
-
};
|
|
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.flags &= ~PENDING;
|
|
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,14 +1,19 @@
|
|
|
1
1
|
export declare const PENDING: number;
|
|
2
2
|
export declare const RUNNING: number;
|
|
3
3
|
export declare const NEEDS_RERUN: number;
|
|
4
|
+
export declare const DISPOSED: number;
|
|
4
5
|
export interface DependencySource {
|
|
5
6
|
subsHead: DependencyLink | null;
|
|
6
7
|
currentLink: DependencyLink | null;
|
|
8
|
+
version: number;
|
|
9
|
+
refresh: (() => void) | null;
|
|
10
|
+
computedCount: number;
|
|
7
11
|
}
|
|
8
|
-
interface DependencyLink {
|
|
12
|
+
export interface DependencyLink {
|
|
9
13
|
source: DependencySource;
|
|
10
14
|
subscriber: Subscriber;
|
|
11
15
|
trackId: number;
|
|
16
|
+
seenVersion: number;
|
|
12
17
|
rollbackCurrentLink: DependencyLink | null;
|
|
13
18
|
nextInSource: DependencyLink | null;
|
|
14
19
|
prevInSource: DependencyLink | null;
|
|
@@ -17,10 +22,14 @@ interface DependencyLink {
|
|
|
17
22
|
}
|
|
18
23
|
export interface Subscriber {
|
|
19
24
|
notify: () => void;
|
|
25
|
+
invalidate: () => void;
|
|
20
26
|
depsHead: DependencyLink | null;
|
|
21
27
|
depsTail: DependencyLink | null;
|
|
22
28
|
trackId: number;
|
|
23
29
|
flags: number;
|
|
30
|
+
initialized: boolean;
|
|
31
|
+
isComputed: boolean;
|
|
32
|
+
pendingNext: Subscriber | null;
|
|
24
33
|
}
|
|
25
34
|
declare let _activeSubscriber: Subscriber | null;
|
|
26
35
|
export { _activeSubscriber };
|
|
@@ -29,12 +38,17 @@ export declare function getActiveSubscriber(): Subscriber | null;
|
|
|
29
38
|
export declare function trackDependency(source: DependencySource): void;
|
|
30
39
|
export declare function trackSubscriber<T>(subscriber: Subscriber, fn: () => T): T;
|
|
31
40
|
export declare function cleanupSubscriber(subscriber: Subscriber): void;
|
|
41
|
+
export declare function invalidateSource(source: DependencySource): void;
|
|
32
42
|
export declare function scheduleSource(source: DependencySource): void;
|
|
33
43
|
export declare function batch<T>(fn: () => T): T;
|
|
34
44
|
export declare function untracked<T>(fn: () => T): T;
|
|
35
45
|
/**
|
|
36
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
|
+
*
|
|
37
51
|
* @param fn The function to execute.
|
|
38
52
|
* @returns An unsubscribe function.
|
|
39
53
|
*/
|
|
40
|
-
export declare function effect(fn: () => void): () => void;
|
|
54
|
+
export declare function effect(fn: () => void | (() => void)): () => void;
|
package/dist/effect.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// ─── Subscriber flags ───────────────────────────────────────────────────────
|
|
2
|
-
export const PENDING = 1 << 0; // queued in
|
|
2
|
+
export const PENDING = 1 << 0; // queued in the notification scheduler
|
|
3
3
|
export const RUNNING = 1 << 1; // effect is currently executing
|
|
4
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
|
|
5
6
|
let _activeSubscriber = null;
|
|
6
|
-
// Exported so signal.ts can do an inline null-check in the getter
|
|
7
|
-
// 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.
|
|
8
9
|
export { _activeSubscriber };
|
|
9
10
|
export function createDependencySource() {
|
|
10
|
-
return { subsHead: null, currentLink: null };
|
|
11
|
+
return { subsHead: null, currentLink: null, version: 0, refresh: null, computedCount: 0 };
|
|
11
12
|
}
|
|
12
13
|
export function getActiveSubscriber() {
|
|
13
14
|
return _activeSubscriber;
|
|
@@ -27,8 +28,9 @@ function prepareSources(subscriber) {
|
|
|
27
28
|
function linkDependency(source, subscriber) {
|
|
28
29
|
const existing = source.currentLink;
|
|
29
30
|
if (existing !== null && existing.subscriber === subscriber) {
|
|
30
|
-
// Stable dep:
|
|
31
|
+
// Stable dep: mark as seen in this run and record the observed version.
|
|
31
32
|
existing.trackId = subscriber.trackId;
|
|
33
|
+
existing.seenVersion = source.version;
|
|
32
34
|
return;
|
|
33
35
|
}
|
|
34
36
|
// New dep: create a link and install it. Save whatever was in currentLink
|
|
@@ -37,6 +39,7 @@ function linkDependency(source, subscriber) {
|
|
|
37
39
|
source,
|
|
38
40
|
subscriber,
|
|
39
41
|
trackId: subscriber.trackId,
|
|
42
|
+
seenVersion: source.version,
|
|
40
43
|
rollbackCurrentLink: existing,
|
|
41
44
|
nextInSource: source.subsHead,
|
|
42
45
|
prevInSource: null,
|
|
@@ -46,6 +49,8 @@ function linkDependency(source, subscriber) {
|
|
|
46
49
|
if (source.subsHead)
|
|
47
50
|
source.subsHead.prevInSource = link;
|
|
48
51
|
source.subsHead = link;
|
|
52
|
+
if (subscriber.isComputed)
|
|
53
|
+
source.computedCount++;
|
|
49
54
|
if (subscriber.depsTail) {
|
|
50
55
|
subscriber.depsTail.nextInSubscriber = link;
|
|
51
56
|
}
|
|
@@ -57,6 +62,8 @@ function linkDependency(source, subscriber) {
|
|
|
57
62
|
}
|
|
58
63
|
function unlinkDependency(link) {
|
|
59
64
|
const { source, subscriber } = link;
|
|
65
|
+
if (subscriber.isComputed)
|
|
66
|
+
source.computedCount--;
|
|
60
67
|
if (link.prevInSource) {
|
|
61
68
|
link.prevInSource.nextInSource = link.nextInSource;
|
|
62
69
|
}
|
|
@@ -123,28 +130,107 @@ export function cleanupSubscriber(subscriber) {
|
|
|
123
130
|
unlinkDependency(link);
|
|
124
131
|
link = next;
|
|
125
132
|
}
|
|
126
|
-
subscriber.
|
|
133
|
+
subscriber.depsHead = null;
|
|
134
|
+
subscriber.depsTail = null;
|
|
135
|
+
subscriber.flags = DISPOSED;
|
|
127
136
|
}
|
|
128
|
-
// ───
|
|
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 ─────────────────────────────────────────────────
|
|
129
151
|
let _batchDepth = 0;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|
|
137
160
|
}
|
|
138
161
|
else {
|
|
139
|
-
subscriber
|
|
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--;
|
|
140
204
|
}
|
|
141
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.
|
|
142
208
|
export function scheduleSource(source) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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();
|
|
148
234
|
}
|
|
149
235
|
}
|
|
150
236
|
export function batch(fn) {
|
|
@@ -154,14 +240,8 @@ export function batch(fn) {
|
|
|
154
240
|
}
|
|
155
241
|
finally {
|
|
156
242
|
_batchDepth--;
|
|
157
|
-
if (_batchDepth === 0) {
|
|
158
|
-
|
|
159
|
-
for (const sub of pending) {
|
|
160
|
-
if (!(sub.flags & PENDING))
|
|
161
|
-
continue;
|
|
162
|
-
sub.flags &= ~PENDING;
|
|
163
|
-
sub.notify();
|
|
164
|
-
}
|
|
243
|
+
if (_batchDepth === 0 && _pendingHead !== null) {
|
|
244
|
+
flushPendingNotifications();
|
|
165
245
|
}
|
|
166
246
|
}
|
|
167
247
|
}
|
|
@@ -178,37 +258,120 @@ export function untracked(fn) {
|
|
|
178
258
|
}
|
|
179
259
|
// ─── Effect ──────────────────────────────────────────────────────────────────
|
|
180
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
|
+
}
|
|
181
277
|
/**
|
|
182
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
|
+
*
|
|
183
283
|
* @param fn The function to execute.
|
|
184
284
|
* @returns An unsubscribe function.
|
|
185
285
|
*/
|
|
186
286
|
export function effect(fn) {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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)
|
|
191
320
|
return;
|
|
321
|
+
if (_batchDepth !== 0) {
|
|
322
|
+
if (!(this.flags & PENDING))
|
|
323
|
+
enqueueSubscriber(this);
|
|
192
324
|
}
|
|
193
|
-
|
|
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;
|
|
194
345
|
let iterations = 0;
|
|
195
346
|
try {
|
|
196
347
|
do {
|
|
197
348
|
if (++iterations > MAX_EFFECT_ITERATIONS) {
|
|
198
349
|
throw new Error('Circular dependency detected: effect exceeded maximum re-run limit');
|
|
199
350
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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);
|
|
203
372
|
}
|
|
204
373
|
finally {
|
|
205
|
-
|
|
374
|
+
this.flags &= ~RUNNING;
|
|
206
375
|
}
|
|
207
|
-
}
|
|
208
|
-
effectObj.depsHead = null;
|
|
209
|
-
effectObj.depsTail = null;
|
|
210
|
-
effectObj.trackId = 0;
|
|
211
|
-
effectObj.flags = 0;
|
|
212
|
-
effectObj.notify();
|
|
213
|
-
return () => cleanupSubscriber(effectObj);
|
|
376
|
+
}
|
|
214
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
|
-
|
|
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.
|
|
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
|
}
|