@reely/signals 0.0.0-stage → 0.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 +108 -2
- package/dist/index.d.ts +82 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/signals-CHdm5V62.js +294 -0
- package/dist/signals-CHdm5V62.js.map +1 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +2 -0
- package/package.json +72 -4
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @reely/signals
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Signals, computed values and effects: a push-pull graph with owners that release what a piece of work subscribed. Typed, tree-shakeable, no DOM. [`@reely/dommy`](https://www.npmjs.com/package/@reely/dommy) binds them to the DOM and re-exports them.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm i @reely/signals
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Signals
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { batch, computed, effect, signal, untracked } from '@reely/signals';
|
|
13
|
+
|
|
14
|
+
const tickets = signal(1);
|
|
15
|
+
const total = computed(() => tickets.value * 12.5); // euros
|
|
16
|
+
|
|
17
|
+
effect(() => console.log(`Pay €${total.value}`)); // runs now, then after every change
|
|
18
|
+
batch(() => {
|
|
19
|
+
tickets.value += 1; // effects run once, when the batch ends
|
|
20
|
+
tickets.value += 1;
|
|
21
|
+
});
|
|
22
|
+
untracked(() => tickets.value); // reads without subscribing; same as `tickets.peek()`
|
|
23
|
+
|
|
24
|
+
// the style of Angular works as well: a signal and a computed are functions
|
|
25
|
+
tickets(); // reads, as `tickets.value` does
|
|
26
|
+
tickets.set(4); // writes, as `tickets.value = 4` does
|
|
27
|
+
tickets.update((n) => n + 1); // writes from the value, read without subscribing
|
|
28
|
+
total(); // a computed reads the same way
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Pick the style you like; the two mix freely. A `ReactiveValue<T>` is anything read that way: a signal, a computed or a getter of them.
|
|
32
|
+
|
|
33
|
+
Effects run synchronously, and a computed recomputes only when it is read after a change. A write of an equal value (`Object.is`, or the `equals` you pass) changes nothing. A signal holds any value, a function included: `set` and `.value =` store it; `update` calls only the function you pass it.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { signal } from '@reely/signals';
|
|
37
|
+
|
|
38
|
+
const garage = { cars: ['Volvo'] };
|
|
39
|
+
const state = signal(garage, { equals: false }); // changed in place: every write notifies
|
|
40
|
+
garage.cars.push('Saab');
|
|
41
|
+
state.value = garage;
|
|
42
|
+
|
|
43
|
+
const standings = signal({ leader: 'Ada', lap: 3 }, { equals: (previous, next) => previous.leader === next.leader });
|
|
44
|
+
standings.value = { leader: 'Ada', lap: 4 }; // the same leader: nothing is notified, the value stays
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`equals` decides whether the signal notifies. What derives from it compares its own result by `Object.is`: a `computed` that returns the same object notifies nothing, so derive the part that changes (`() => state.value.cars.length`).
|
|
48
|
+
|
|
49
|
+
## Owners
|
|
50
|
+
|
|
51
|
+
An owner holds what a piece of work must release: the effects created under it, `onCleanup` callbacks and nested owners. `withOwner(fn)` runs `fn` under a new owner and passes it `dispose`.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { effect, onCleanup, signal, withOwner } from '@reely/signals';
|
|
55
|
+
|
|
56
|
+
const lap = signal(1);
|
|
57
|
+
|
|
58
|
+
const stopRace = withOwner((dispose) => {
|
|
59
|
+
effect(() => console.log(`lap ${lap.value}`));
|
|
60
|
+
const timer = setInterval(() => lap.update((n) => n + 1), 60_000);
|
|
61
|
+
onCleanup(() => clearInterval(timer));
|
|
62
|
+
return dispose;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
stopRace(); // the effect unsubscribes and the timer stops
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Each run of an effect has its own owner: `onCleanup` inside it runs before the next run and when the effect is disposed, and the effects it created go with it. `getOwner()` captures the running owner, for code that runs later to pass to `withOwner(fn, owner)`.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { effect, onCleanup, signal } from '@reely/signals';
|
|
72
|
+
|
|
73
|
+
const draft = signal('');
|
|
74
|
+
const typing = signal(false); // shows "typing…" to the other side of a chat
|
|
75
|
+
|
|
76
|
+
effect(() => {
|
|
77
|
+
if (draft.value === '') return;
|
|
78
|
+
typing.value = true;
|
|
79
|
+
const timer = setTimeout(() => (typing.value = false), 2000);
|
|
80
|
+
onCleanup(() => clearTimeout(timer)); // the next keystroke, or dispose, cancels it
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Testing
|
|
85
|
+
|
|
86
|
+
`@reely/signals/testing` counts what still listens to a signal, so a test can check that `dispose` released everything:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { effect, signal, withOwner } from '@reely/signals';
|
|
90
|
+
import { subscriberCount } from '@reely/signals/testing';
|
|
91
|
+
|
|
92
|
+
const lap = signal(1);
|
|
93
|
+
const stopRace = withOwner((dispose) => {
|
|
94
|
+
effect(() => console.log(`lap ${lap.value}`));
|
|
95
|
+
return dispose;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
stopRace();
|
|
99
|
+
console.assert(subscriberCount(lap) === 0);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
105
|
+
|
|
106
|
+
## Credits
|
|
107
|
+
|
|
108
|
+
- The signal core implements the push-pull graph known from [Reactively](https://github.com/milomg/reactively), [@preact/signals-core](https://github.com/preactjs/signals) and [alien-signals](https://github.com/stackblitz/alien-signals), written from the concept.
|
|
109
|
+
- The signal tests are adapted from [@preact/signals-core](https://github.com/preactjs/signals), Copyright (c) 2022-present Preact Team, MIT licence.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Groups writes: effects run once, when the outermost `batch` ends, even if `fn` throws. */
|
|
2
|
+
export declare const batch: <T>(fn: () => T) => T;
|
|
3
|
+
|
|
4
|
+
export declare interface Computed<T> extends Subscribable<T> {
|
|
5
|
+
(): T;
|
|
6
|
+
get value(): T;
|
|
7
|
+
/** Reads the value without subscribing the running effect or computed to it. */
|
|
8
|
+
peek(): T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A value derived from signals, recomputed only after one of them changes. What `fn` throws is
|
|
13
|
+
* rethrown on every read until then.
|
|
14
|
+
*/
|
|
15
|
+
export declare const computed: <T>(fn: () => T) => Computed<T>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs `fn` now and after every change of what it reads; what a run registers is released before
|
|
19
|
+
* the next one. Returns `dispose`, also reachable as `this.dispose()` inside `fn`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const effect: (fn: () => void) => (() => void);
|
|
22
|
+
|
|
23
|
+
declare type Equality<T> = {
|
|
24
|
+
bivarianceHack(previous: T, next: T): boolean;
|
|
25
|
+
}['bivarianceHack'];
|
|
26
|
+
|
|
27
|
+
/** For code that runs later, such as a callback creating effects, to pass to `withOwner`. */
|
|
28
|
+
export declare const getOwner: () => Nullable<Owner>;
|
|
29
|
+
|
|
30
|
+
declare type Nil = null | undefined;
|
|
31
|
+
|
|
32
|
+
declare type Nullable<T> = T | Nil;
|
|
33
|
+
|
|
34
|
+
/** Registers a release in the running owner; outside any owner nothing holds it. */
|
|
35
|
+
export declare const onCleanup: (cleanup: () => void) => void;
|
|
36
|
+
|
|
37
|
+
/** What a piece of work must release when it ends: its effects, cleanups and nested owners. */
|
|
38
|
+
export declare interface Owner {
|
|
39
|
+
readonly cleanups: Set<() => void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A value read reactively: a signal, a computed or any getter of them; an effect or computed that reads it follows it. */
|
|
43
|
+
export declare type ReactiveValue<T> = () => T;
|
|
44
|
+
|
|
45
|
+
export declare interface Signal<T> extends Subscribable<T> {
|
|
46
|
+
(): T;
|
|
47
|
+
get value(): T;
|
|
48
|
+
set value(value: T);
|
|
49
|
+
/** Writes `value`, as `.value =` does. */
|
|
50
|
+
set(value: T): void;
|
|
51
|
+
/** Writes what `fn` makes of the value, read without subscribing the running effect or computed to it. */
|
|
52
|
+
update(fn: (value: T) => T): void;
|
|
53
|
+
/** Reads the value without subscribing the running effect or computed to it. */
|
|
54
|
+
peek(): T;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A value that effects and computeds re-read when it is written; a function is held as a value too. */
|
|
58
|
+
export declare const signal: <T>(init: T, options?: SignalOptions<T>) => Signal<T>;
|
|
59
|
+
|
|
60
|
+
/** How a signal tells a write that changes nothing from one that notifies. */
|
|
61
|
+
export declare interface SignalOptions<T> {
|
|
62
|
+
/**
|
|
63
|
+
* Whether a write leaves the value unchanged, so nothing is notified; `Object.is` by default.
|
|
64
|
+
* `false` (or `() => false`) notifies every write, for an object changed in place.
|
|
65
|
+
*/
|
|
66
|
+
equals?: Equality<T> | false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
declare interface Subscribable<T> {
|
|
70
|
+
subscribe(cb: (value: T, prevValue?: T) => void): () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Runs `fn` without subscribing the running effect or computed to what it reads. */
|
|
74
|
+
export declare const untracked: <T>(fn: () => T) => T;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Runs `fn` under a new owner nested in `parent` and passes it `dispose`. Every cleanup runs even
|
|
78
|
+
* when one throws; the first error is rethrown after all have run.
|
|
79
|
+
*/
|
|
80
|
+
export declare const withOwner: <T>(fn: (dispose: () => void) => T, parent?: Nullable<Owner>) => T;
|
|
81
|
+
|
|
82
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { a as subscribe, c as write, i as signal$1, l as noop, n as computed$1, r as effect$1, s as untracked, t as batch } from "./signals-CHdm5V62.js";
|
|
2
|
+
import { hasSome } from "@reely/basics";
|
|
3
|
+
//#region ../utils/dist/lib/objects/setPrototype.js
|
|
4
|
+
function setPrototype(proto, object) {
|
|
5
|
+
if (!Reflect.setPrototypeOf(object, proto)) throw new Error("Failed to set prototype of provided object.");
|
|
6
|
+
return object;
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/lib/owner.ts
|
|
10
|
+
var currentOwner = null;
|
|
11
|
+
/** For code that runs later, such as a callback creating effects, to pass to `withOwner`. */
|
|
12
|
+
var getOwner = () => currentOwner;
|
|
13
|
+
/** Registers a release in the running owner; outside any owner nothing holds it. */
|
|
14
|
+
var onCleanup = (cleanup) => {
|
|
15
|
+
currentOwner?.cleanups.add(cleanup);
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Runs `fn` under a new owner nested in `parent` and passes it `dispose`. Every cleanup runs even
|
|
19
|
+
* when one throws; the first error is rethrown after all have run.
|
|
20
|
+
*/
|
|
21
|
+
var withOwner = (fn, parent = currentOwner) => {
|
|
22
|
+
const owner = { cleanups: /* @__PURE__ */ new Set() };
|
|
23
|
+
const dispose = () => {
|
|
24
|
+
parent?.cleanups.delete(dispose);
|
|
25
|
+
const cleanups = [...owner.cleanups].reverse();
|
|
26
|
+
owner.cleanups.clear();
|
|
27
|
+
let failure = null;
|
|
28
|
+
for (const cleanup of cleanups) try {
|
|
29
|
+
cleanup();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
failure ??= { error };
|
|
32
|
+
}
|
|
33
|
+
if (hasSome(failure)) throw failure.error;
|
|
34
|
+
};
|
|
35
|
+
parent?.cleanups.add(dispose);
|
|
36
|
+
const previous = currentOwner;
|
|
37
|
+
currentOwner = owner;
|
|
38
|
+
try {
|
|
39
|
+
return fn(dispose);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
currentOwner = previous;
|
|
42
|
+
dispose();
|
|
43
|
+
throw error;
|
|
44
|
+
} finally {
|
|
45
|
+
currentOwner = previous;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/lib/signal.ts
|
|
50
|
+
/** A value that effects and computeds re-read when it is written; a function is held as a value too. */
|
|
51
|
+
var signal = (init, options) => setPrototype(signalProto, signal$1(init, options));
|
|
52
|
+
/**
|
|
53
|
+
* A value derived from signals, recomputed only after one of them changes. What `fn` throws is
|
|
54
|
+
* rethrown on every read until then.
|
|
55
|
+
*/
|
|
56
|
+
var computed = (fn) => setPrototype(computedProto, computed$1(fn));
|
|
57
|
+
/**
|
|
58
|
+
* Runs `fn` now and after every change of what it reads; what a run registers is released before
|
|
59
|
+
* the next one. Returns `dispose`, also reachable as `this.dispose()` inside `fn`.
|
|
60
|
+
*/
|
|
61
|
+
var effect = (fn) => {
|
|
62
|
+
const parent = getOwner();
|
|
63
|
+
let disposeRun = noop;
|
|
64
|
+
let running = false;
|
|
65
|
+
let disposed = false;
|
|
66
|
+
let unsubscribe = noop;
|
|
67
|
+
let body = noop;
|
|
68
|
+
const releaseRun = () => {
|
|
69
|
+
const release = disposeRun;
|
|
70
|
+
disposeRun = noop;
|
|
71
|
+
untracked(() => batch(release));
|
|
72
|
+
};
|
|
73
|
+
const dispose = () => {
|
|
74
|
+
if (disposed) return;
|
|
75
|
+
disposed = true;
|
|
76
|
+
parent?.cleanups.delete(dispose);
|
|
77
|
+
body = noop;
|
|
78
|
+
unsubscribe();
|
|
79
|
+
unsubscribe = noop;
|
|
80
|
+
if (!running) releaseRun();
|
|
81
|
+
};
|
|
82
|
+
body = fn.bind({ dispose });
|
|
83
|
+
const run = () => {
|
|
84
|
+
try {
|
|
85
|
+
releaseRun();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
dispose();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
if (disposed) return;
|
|
91
|
+
running = true;
|
|
92
|
+
try {
|
|
93
|
+
withOwner((disposeOwner) => {
|
|
94
|
+
disposeRun = disposeOwner;
|
|
95
|
+
body();
|
|
96
|
+
}, null);
|
|
97
|
+
} finally {
|
|
98
|
+
running = false;
|
|
99
|
+
if (disposed) releaseRun();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
parent?.cleanups.add(dispose);
|
|
103
|
+
try {
|
|
104
|
+
const release = effect$1(run);
|
|
105
|
+
if (disposed) release();
|
|
106
|
+
else unsubscribe = release;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
dispose();
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
return dispose;
|
|
112
|
+
};
|
|
113
|
+
var computedProto = {
|
|
114
|
+
get value() {
|
|
115
|
+
return this();
|
|
116
|
+
},
|
|
117
|
+
peek() {
|
|
118
|
+
return untracked(this);
|
|
119
|
+
},
|
|
120
|
+
subscribe(cb) {
|
|
121
|
+
return subscribe(this, cb);
|
|
122
|
+
},
|
|
123
|
+
toString() {
|
|
124
|
+
return String(untracked(this));
|
|
125
|
+
},
|
|
126
|
+
valueOf() {
|
|
127
|
+
return untracked(this);
|
|
128
|
+
},
|
|
129
|
+
toJSON() {
|
|
130
|
+
return untracked(this);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
var signalProto = {
|
|
134
|
+
__proto__: computedProto,
|
|
135
|
+
get value() {
|
|
136
|
+
return this();
|
|
137
|
+
},
|
|
138
|
+
set value(value) {
|
|
139
|
+
write(this, value);
|
|
140
|
+
},
|
|
141
|
+
set(value) {
|
|
142
|
+
write(this, value);
|
|
143
|
+
},
|
|
144
|
+
update(fn) {
|
|
145
|
+
write(this, fn(untracked(this)));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
//#endregion
|
|
149
|
+
export { batch, computed, effect, getOwner, onCleanup, signal, untracked, withOwner };
|
|
150
|
+
|
|
151
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["reelx.signal","reelx.computed","reelx.batch","reelx.effect","reelx.untracked","reelx.subscribe"],"sources":["../../utils/dist/lib/objects/setPrototype.js","../src/lib/owner.ts","../src/lib/signal.ts"],"sourcesContent":["export function setPrototype(proto, object) {\n if (!Reflect.setPrototypeOf(object, proto)) {\n throw new Error('Failed to set prototype of provided object.');\n }\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n return object;\n}\n","import { hasSome } from '@reely/basics';\nimport type { Nullable } from '@reely/utils';\n\n/** What a piece of work must release when it ends: its effects, cleanups and nested owners. */\nexport interface Owner {\n readonly cleanups: Set<() => void>;\n}\n\nlet currentOwner: Nullable<Owner> = null;\n\n/** For code that runs later, such as a callback creating effects, to pass to `withOwner`. */\nexport const getOwner = (): Nullable<Owner> => currentOwner;\n\n/** Registers a release in the running owner; outside any owner nothing holds it. */\nexport const onCleanup = (cleanup: () => void): void => {\n currentOwner?.cleanups.add(cleanup);\n};\n\n/**\n * Runs `fn` under a new owner nested in `parent` and passes it `dispose`. Every cleanup runs even\n * when one throws; the first error is rethrown after all have run.\n */\nexport const withOwner = <T>(fn: (dispose: () => void) => T, parent: Nullable<Owner> = currentOwner): T => {\n const owner: Owner = { cleanups: new Set() };\n const dispose = (): void => {\n parent?.cleanups.delete(dispose);\n // reverse order of creation, like a stack of resources\n const cleanups = [...owner.cleanups].reverse();\n owner.cleanups.clear();\n let failure: Nullable<{ readonly error: unknown }> = null;\n for (const cleanup of cleanups) {\n try {\n cleanup();\n } catch (error) {\n failure ??= { error };\n }\n }\n if (hasSome(failure)) {\n throw failure.error;\n }\n };\n parent?.cleanups.add(dispose);\n\n const previous = currentOwner;\n currentOwner = owner;\n try {\n return fn(dispose);\n } catch (error) {\n // work that failed leaves nothing subscribed\n currentOwner = previous;\n dispose();\n throw error;\n } finally {\n currentOwner = previous;\n }\n};\n","import { noop, setPrototype } from '@reely/utils';\n\nimport { getOwner, withOwner } from './owner';\nimport * as reelx from './reelx.core';\n\nimport type { ReactiveValue, SignalOptions } from './reelx.core';\n\nexport { batch, untracked, type ReactiveValue, type SignalOptions } from './reelx.core';\n\ninterface Subscribable<T> {\n subscribe(cb: (value: T, prevValue?: T) => void): () => void;\n}\n\nexport interface Signal<T> extends Subscribable<T> {\n (): T;\n get value(): T;\n set value(value: T);\n /** Writes `value`, as `.value =` does. */\n set(value: T): void;\n /** Writes what `fn` makes of the value, read without subscribing the running effect or computed to it. */\n update(fn: (value: T) => T): void;\n /** Reads the value without subscribing the running effect or computed to it. */\n peek(): T;\n}\n\nexport interface Computed<T> extends Subscribable<T> {\n (): T;\n get value(): T;\n /** Reads the value without subscribing the running effect or computed to it. */\n peek(): T;\n}\n\n/** A value that effects and computeds re-read when it is written; a function is held as a value too. */\nexport const signal = <T>(init: T, options?: SignalOptions<T>): Signal<T> =>\n setPrototype<Signal<T>>(signalProto, reelx.signal(init, options));\n\n/**\n * A value derived from signals, recomputed only after one of them changes. What `fn` throws is\n * rethrown on every read until then.\n */\nexport const computed = <T>(fn: () => T): Computed<T> => setPrototype<Computed<T>>(computedProto, reelx.computed(fn));\n\n/**\n * Runs `fn` now and after every change of what it reads; what a run registers is released before\n * the next one. Returns `dispose`, also reachable as `this.dispose()` inside `fn`.\n */\nexport const effect = (fn: () => void): (() => void) => {\n const parent = getOwner();\n let disposeRun: () => void = noop;\n let running = false;\n let disposed = false;\n let unsubscribe: () => void = noop;\n let body: () => void = noop;\n\n // cleanups read and write signals like any code outside the effect: untracked, in one batch\n const releaseRun = (): void => {\n const release = disposeRun;\n disposeRun = noop;\n reelx.untracked(() => reelx.batch(release));\n };\n\n const dispose = (): void => {\n if (disposed) {\n return;\n }\n disposed = true;\n parent?.cleanups.delete(dispose);\n // a kept `dispose` must not keep the body or the effect's last dependencies alive\n body = noop;\n unsubscribe();\n unsubscribe = noop;\n // a run that disposes its own effect releases its cleanups once it has finished\n if (!running) {\n releaseRun();\n }\n };\n\n // `this` in the body: a run can dispose its own effect, the first run included\n body = fn.bind({ dispose });\n\n const run = (): void => {\n try {\n releaseRun();\n } catch (error) {\n dispose();\n throw error;\n }\n if (disposed) {\n return;\n }\n running = true;\n try {\n withOwner((disposeOwner) => {\n disposeRun = disposeOwner;\n body();\n }, null);\n } finally {\n running = false;\n if (disposed) {\n releaseRun();\n }\n }\n };\n\n parent?.cleanups.add(dispose);\n try {\n const release = reelx.effect(run);\n // the first run may have disposed the effect before its subscription existed\n if (disposed) {\n release();\n } else {\n unsubscribe = release;\n }\n } catch (error) {\n dispose();\n throw error;\n }\n return dispose;\n};\n\nconst computedProto: ThisType<ReactiveValue<unknown>> = {\n get value(): unknown {\n return this();\n },\n peek(): unknown {\n return reelx.untracked(this);\n },\n subscribe(cb: (value: unknown, prevValue?: unknown) => void): () => void {\n return reelx.subscribe(this, cb);\n },\n toString(): string {\n return String(reelx.untracked(this));\n },\n valueOf(): unknown {\n return reelx.untracked(this);\n },\n toJSON(): unknown {\n return reelx.untracked(this);\n },\n};\n\n// `__proto__` in a literal, not a call, so importing the module runs no code\nconst signalProto: ThisType<ReactiveValue<unknown>> = {\n __proto__: computedProto,\n get value(): unknown {\n return this();\n },\n set value(value: unknown) {\n reelx.write(this, value);\n },\n set(value: unknown): void {\n reelx.write(this, value);\n },\n update(fn: (value: unknown) => unknown): void {\n reelx.write(this, fn(reelx.untracked(this)));\n },\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { hasSome } from "@reely/basics";
|
|
2
|
+
//#region ../utils/dist/lib/objects/isInstanceOf.js
|
|
3
|
+
function isInstanceOf(instanceType, instance) {
|
|
4
|
+
return instance instanceof instanceType;
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region ../utils/dist/lib/fp/noop.js
|
|
8
|
+
function noop() {}
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/lib/reelx.core.ts
|
|
11
|
+
var MAX_FLUSH_WAVES = 100;
|
|
12
|
+
var epoch = 0;
|
|
13
|
+
var tracker = null;
|
|
14
|
+
var running = null;
|
|
15
|
+
var batchDepth = 0;
|
|
16
|
+
var queue = [];
|
|
17
|
+
var flushing = false;
|
|
18
|
+
var setTracker = (observer) => {
|
|
19
|
+
const outer = tracker;
|
|
20
|
+
tracker = observer;
|
|
21
|
+
return outer;
|
|
22
|
+
};
|
|
23
|
+
var setRunning = (computation) => {
|
|
24
|
+
const outer = running;
|
|
25
|
+
running = computation;
|
|
26
|
+
return outer;
|
|
27
|
+
};
|
|
28
|
+
var Signal = class {
|
|
29
|
+
value;
|
|
30
|
+
options;
|
|
31
|
+
version = 0;
|
|
32
|
+
observers = /* @__PURE__ */ new Set();
|
|
33
|
+
refresh = noop;
|
|
34
|
+
constructor(value, options) {
|
|
35
|
+
this.value = value;
|
|
36
|
+
this.options = options;
|
|
37
|
+
}
|
|
38
|
+
read = () => {
|
|
39
|
+
tracker?.onRead(this);
|
|
40
|
+
return this.value;
|
|
41
|
+
};
|
|
42
|
+
observe(observer) {
|
|
43
|
+
this.observers.add(observer);
|
|
44
|
+
}
|
|
45
|
+
unobserve(observer) {
|
|
46
|
+
this.observers.delete(observer);
|
|
47
|
+
}
|
|
48
|
+
write(value) {
|
|
49
|
+
const { equals = Object.is } = this.options;
|
|
50
|
+
if (equals !== false && equals(this.value, value)) return;
|
|
51
|
+
this.value = value;
|
|
52
|
+
this.version++;
|
|
53
|
+
epoch++;
|
|
54
|
+
running?.onOwnWrite(this);
|
|
55
|
+
const idle = queue.length === 0;
|
|
56
|
+
for (const observer of this.observers) observer.mark(this);
|
|
57
|
+
if (idle && queue.length > 0 && batchDepth === 0 && !flushing) flushSync();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var Computation = class {
|
|
61
|
+
fn;
|
|
62
|
+
version = 0;
|
|
63
|
+
needsCheck = false;
|
|
64
|
+
sources = /* @__PURE__ */ new Map();
|
|
65
|
+
outcome = null;
|
|
66
|
+
runCount = 0;
|
|
67
|
+
checkedAt = -1;
|
|
68
|
+
markedAt = -1;
|
|
69
|
+
deafTo = null;
|
|
70
|
+
constructor(fn) {
|
|
71
|
+
this.fn = fn;
|
|
72
|
+
}
|
|
73
|
+
mark(written) {
|
|
74
|
+
if (this.markedAt === epoch) return;
|
|
75
|
+
this.markedAt = epoch;
|
|
76
|
+
if (this === running) {
|
|
77
|
+
this.deafTo ??= /* @__PURE__ */ new Set();
|
|
78
|
+
this.deafTo.add(written);
|
|
79
|
+
} else if (!this.deafTo?.has(written)) this.onMarked(written);
|
|
80
|
+
}
|
|
81
|
+
onOwnWrite(source) {
|
|
82
|
+
const link = this.sources.get(source);
|
|
83
|
+
if (hasSome(link)) link.version = source.version;
|
|
84
|
+
}
|
|
85
|
+
onRead(source) {
|
|
86
|
+
this.deafTo?.delete(source);
|
|
87
|
+
const link = this.sources.get(source);
|
|
88
|
+
if (hasSome(link)) {
|
|
89
|
+
link.version = source.version;
|
|
90
|
+
link.readIn = this.runCount;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.sources.set(source, {
|
|
94
|
+
version: source.version,
|
|
95
|
+
readIn: this.runCount
|
|
96
|
+
});
|
|
97
|
+
if (this.linked) source.observe(this);
|
|
98
|
+
}
|
|
99
|
+
refresh() {
|
|
100
|
+
const current = this.outcome;
|
|
101
|
+
if (hasSome(current) && !this.needsCheck && (this.linked || this.checkedAt === epoch)) return current;
|
|
102
|
+
this.needsCheck = false;
|
|
103
|
+
this.checkedAt = epoch;
|
|
104
|
+
if (hasSome(current) && !this.sourceChanged()) return current;
|
|
105
|
+
const { fn } = this;
|
|
106
|
+
this.runCount++;
|
|
107
|
+
this.deafTo = null;
|
|
108
|
+
const outer = setTracker(this);
|
|
109
|
+
const outerRunning = setRunning(this);
|
|
110
|
+
let next;
|
|
111
|
+
try {
|
|
112
|
+
next = {
|
|
113
|
+
threw: false,
|
|
114
|
+
value: fn()
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
next = {
|
|
118
|
+
threw: true,
|
|
119
|
+
value: error
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
setTracker(outer);
|
|
123
|
+
setRunning(outerRunning);
|
|
124
|
+
this.dropUnread();
|
|
125
|
+
if (!hasSome(current) || current.threw !== next.threw || !Object.is(current.value, next.value)) this.version++;
|
|
126
|
+
this.outcome = next;
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
detach() {
|
|
130
|
+
for (const source of this.sources.keys()) source.unobserve(this);
|
|
131
|
+
}
|
|
132
|
+
unlink(source) {
|
|
133
|
+
this.sources.delete(source);
|
|
134
|
+
source.unobserve(this);
|
|
135
|
+
}
|
|
136
|
+
sourceChanged() {
|
|
137
|
+
for (const [source, { version }] of this.sources) {
|
|
138
|
+
source.refresh();
|
|
139
|
+
if (source.version !== version) return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
dropUnread() {
|
|
144
|
+
for (const [source, link] of this.sources) if (link.readIn !== this.runCount) this.unlink(source);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
var Computed = class extends Computation {
|
|
148
|
+
observers = /* @__PURE__ */ new Set();
|
|
149
|
+
get linked() {
|
|
150
|
+
return this.observers.size > 0;
|
|
151
|
+
}
|
|
152
|
+
read = () => {
|
|
153
|
+
const outcome = this.refresh();
|
|
154
|
+
tracker?.onRead(this);
|
|
155
|
+
if (outcome.threw) throw outcome.value;
|
|
156
|
+
return outcome.value;
|
|
157
|
+
};
|
|
158
|
+
observe(observer) {
|
|
159
|
+
if (this.observers.size === 0) for (const source of this.sources.keys()) source.observe(this);
|
|
160
|
+
this.observers.add(observer);
|
|
161
|
+
}
|
|
162
|
+
unobserve(observer) {
|
|
163
|
+
if (this.observers.delete(observer) && this.observers.size === 0) this.detach();
|
|
164
|
+
}
|
|
165
|
+
onMarked(written) {
|
|
166
|
+
this.needsCheck = true;
|
|
167
|
+
for (const observer of this.observers) observer.mark(written);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
var Effect = class extends Computation {
|
|
171
|
+
disposed = false;
|
|
172
|
+
get linked() {
|
|
173
|
+
return !this.disposed;
|
|
174
|
+
}
|
|
175
|
+
dispose = () => {
|
|
176
|
+
this.disposed = true;
|
|
177
|
+
this.detach();
|
|
178
|
+
this.sources.clear();
|
|
179
|
+
};
|
|
180
|
+
run() {
|
|
181
|
+
if (this.disposed) return;
|
|
182
|
+
const before = this.outcome;
|
|
183
|
+
const after = this.refresh();
|
|
184
|
+
if (after !== before && after.threw) throw after.value;
|
|
185
|
+
}
|
|
186
|
+
dequeue() {
|
|
187
|
+
this.needsCheck = false;
|
|
188
|
+
}
|
|
189
|
+
onMarked() {
|
|
190
|
+
if (!this.needsCheck) {
|
|
191
|
+
this.needsCheck = true;
|
|
192
|
+
queue.push(this);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
/** Runs the queued effects now; what they write joins as the next wave. Every effect runs, the first error is rethrown. */
|
|
197
|
+
var flushSync = () => {
|
|
198
|
+
if (flushing) return;
|
|
199
|
+
flushing = true;
|
|
200
|
+
let failure = null;
|
|
201
|
+
try {
|
|
202
|
+
let wave = 0;
|
|
203
|
+
while (queue.length > 0) {
|
|
204
|
+
wave++;
|
|
205
|
+
if (wave > MAX_FLUSH_WAVES) {
|
|
206
|
+
const dropped = queue;
|
|
207
|
+
queue = [];
|
|
208
|
+
dropped.forEach((effect) => effect.dequeue());
|
|
209
|
+
throw new Error(`reelx: a cycle of effects, each writes a signal another one reads (${MAX_FLUSH_WAVES} waves)`, { cause: failure?.error });
|
|
210
|
+
}
|
|
211
|
+
const effects = queue;
|
|
212
|
+
queue = [];
|
|
213
|
+
for (const effect of effects) try {
|
|
214
|
+
effect.run();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
failure ??= { error };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
flushing = false;
|
|
221
|
+
}
|
|
222
|
+
if (hasSome(failure)) throw failure.error;
|
|
223
|
+
};
|
|
224
|
+
var nodes = /* @__PURE__ */ new WeakMap();
|
|
225
|
+
var toReactiveValue = (node) => {
|
|
226
|
+
nodes.set(node.read, node);
|
|
227
|
+
return node.read;
|
|
228
|
+
};
|
|
229
|
+
/** A value that effects and computeds re-read when it is written; a function is held as a value too. */
|
|
230
|
+
var signal = (initial, options = {}) => toReactiveValue(new Signal(initial, options));
|
|
231
|
+
/** A value derived from what `fn` reads, recomputed on read after one of them changes; what `fn` throws is rethrown until then. */
|
|
232
|
+
var computed = (fn) => toReactiveValue(new Computed(fn));
|
|
233
|
+
/** Writes `value` to the signal `read` reads (a computed's reader is ignored); a value its `equals` finds unchanged changes nothing. */
|
|
234
|
+
var write = (read, value) => {
|
|
235
|
+
const node = nodes.get(read);
|
|
236
|
+
if (isInstanceOf(Signal, node)) node.write(value);
|
|
237
|
+
};
|
|
238
|
+
/** Runs `fn` now and again after every change of what it read. Returns `dispose`; creation that throws leaves nothing subscribed. */
|
|
239
|
+
var effect = (fn) => {
|
|
240
|
+
const node = new Effect(fn);
|
|
241
|
+
try {
|
|
242
|
+
batch(() => {
|
|
243
|
+
try {
|
|
244
|
+
node.run();
|
|
245
|
+
} catch (error) {
|
|
246
|
+
node.dispose();
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
node.dispose();
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
return node.dispose;
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Calls `cb` with the value of `read` and the one before it: now, unless it is `undefined`, then
|
|
258
|
+
* after every change `read` notifies. Returns `unsubscribe`.
|
|
259
|
+
*/
|
|
260
|
+
var subscribe = (read, cb) => {
|
|
261
|
+
let last;
|
|
262
|
+
let started = false;
|
|
263
|
+
return effect(() => {
|
|
264
|
+
const value = read();
|
|
265
|
+
const previous = last;
|
|
266
|
+
last = value;
|
|
267
|
+
if (started || !Object.is(value, void 0)) untracked(() => cb(value, previous));
|
|
268
|
+
started = true;
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
/** Groups writes: effects run once, when the outermost `batch` ends, even if `fn` throws. */
|
|
272
|
+
var batch = (fn) => {
|
|
273
|
+
batchDepth++;
|
|
274
|
+
try {
|
|
275
|
+
return fn();
|
|
276
|
+
} finally {
|
|
277
|
+
if (--batchDepth === 0) flushSync();
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
/** Runs `fn` without subscribing the running effect or computed to what it reads. */
|
|
281
|
+
var untracked = (fn) => {
|
|
282
|
+
const outer = setTracker(null);
|
|
283
|
+
try {
|
|
284
|
+
return fn();
|
|
285
|
+
} finally {
|
|
286
|
+
setTracker(outer);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
/** How many effects and computeds read `read` now: tests count them to prove that `dispose` released them. */
|
|
290
|
+
var subscriberCount = (read) => nodes.get(read)?.observers.size ?? 0;
|
|
291
|
+
//#endregion
|
|
292
|
+
export { subscribe as a, write as c, signal as i, noop as l, computed as n, subscriberCount as o, effect as r, untracked as s, batch as t };
|
|
293
|
+
|
|
294
|
+
//# sourceMappingURL=signals-CHdm5V62.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signals-CHdm5V62.js","names":[],"sources":["../../utils/dist/lib/objects/isInstanceOf.js","../../utils/dist/lib/fp/noop.js","../src/lib/reelx.core.ts"],"sourcesContent":["export default function isInstanceOf(instanceType, instance) {\n return instance instanceof instanceType;\n}\n","export default function noop() {\n return undefined;\n}\n","// A push-pull signal graph: a write marks what may have changed, a read brings a node up to date.\nimport { hasSome } from '@reely/basics';\nimport type { Nullable } from '@reely/utils';\nimport { isInstanceOf, noop } from '@reely/utils';\n\n/** A value read reactively: a signal, a computed or any getter of them; an effect or computed that reads it follows it. */\nexport type ReactiveValue<T> = () => T;\n\n// a method type: its parameters stay bivariant, so a `Signal<T>` is a `Signal<unknown>`\ntype Equality<T> = { bivarianceHack(previous: T, next: T): boolean }['bivarianceHack'];\n\n/** How a signal tells a write that changes nothing from one that notifies. */\nexport interface SignalOptions<T> {\n /**\n * Whether a write leaves the value unchanged, so nothing is notified; `Object.is` by default.\n * `false` (or `() => false`) notifies every write, for an object changed in place.\n */\n equals?: Equality<T> | false;\n}\n\ninterface Source {\n // grows with every change of the value, so an observer can tell whether what it read is still current\n readonly version: number;\n readonly observers: ReadonlySet<Computation<unknown>>;\n refresh(): void;\n observe(observer: Computation<unknown>): void;\n unobserve(observer: Computation<unknown>): void;\n}\n\ninterface Link {\n version: number;\n readIn: number;\n}\n\n// the value, or what the computation threw\ntype Outcome<T> = { readonly threw: false; readonly value: T } | { readonly threw: true; readonly value: unknown };\n\nconst MAX_FLUSH_WAVES = 100;\n\n// grows with every write: an unlinked computed hears no writes, but one checked at this epoch is current\nlet epoch = 0;\nlet tracker: Nullable<Computation<unknown>> = null;\n// the computation whose `fn` runs now, tracked or not: its own writes never run it again\nlet running: Nullable<Computation<unknown>> = null;\nlet batchDepth = 0;\nlet queue: Effect[] = [];\nlet flushing = false;\n\nconst setTracker = (observer: Nullable<Computation<unknown>>): Nullable<Computation<unknown>> => {\n const outer = tracker;\n tracker = observer;\n return outer;\n};\n\nconst setRunning = (computation: Nullable<Computation<unknown>>): Nullable<Computation<unknown>> => {\n const outer = running;\n running = computation;\n return outer;\n};\n\nclass Signal<T> implements Source {\n public version = 0;\n public readonly observers = new Set<Computation<unknown>>();\n public readonly refresh = noop;\n\n constructor(private value: T, private readonly options: SignalOptions<T>) {}\n\n public readonly read = (): T => {\n tracker?.onRead(this);\n return this.value;\n };\n\n public observe(observer: Computation<unknown>): void {\n this.observers.add(observer);\n }\n\n public unobserve(observer: Computation<unknown>): void {\n this.observers.delete(observer);\n }\n\n public write(value: T): void {\n const { equals = Object.is } = this.options;\n if (equals !== false && equals(this.value, value)) {\n return;\n }\n this.value = value;\n this.version++;\n epoch++;\n running?.onOwnWrite(this);\n const idle = queue.length === 0;\n for (const observer of this.observers) {\n observer.mark(this);\n }\n if (idle && queue.length > 0 && batchDepth === 0 && !flushing) {\n flushSync();\n }\n }\n}\n\n// runs `fn` recording what it reads, and reruns it on demand only when one of those sources changed\nabstract class Computation<T> {\n public version = 0;\n // a source may have changed; the versions of the sources tell whether one did\n protected needsCheck = false;\n // in the order first read\n protected readonly sources = new Map<Source, Link>();\n protected outcome: Nullable<Outcome<T>> = null;\n private runCount = 0;\n private checkedAt = -1;\n private markedAt = -1;\n private deafTo: Nullable<Set<Source>> = null;\n\n constructor(private readonly fn: () => T) {}\n\n // whether its sources hold it, so that their writes reach it\n protected abstract get linked(): boolean;\n\n // `written` is the signal whose write this mark carries\n public mark(written: Source): void {\n // once per write: a node already marked by an earlier write still passes a new one on\n if (this.markedAt === epoch) {\n return;\n }\n this.markedAt = epoch;\n // A run does not depend on a signal it writes, read directly or through a computed: its own\n // write coming back makes it deaf to that signal until it reads it again or runs again.\n if (this === running) {\n this.deafTo ??= new Set();\n this.deafTo.add(written);\n } else if (!this.deafTo?.has(written)) {\n this.onMarked(written);\n }\n }\n\n // what a run writes itself is not a change it missed\n public onOwnWrite(source: Source): void {\n const link = this.sources.get(source);\n if (hasSome(link)) {\n link.version = source.version;\n }\n }\n\n public onRead(source: Source): void {\n this.deafTo?.delete(source);\n const link = this.sources.get(source);\n if (hasSome(link)) {\n link.version = source.version;\n link.readIn = this.runCount;\n return;\n }\n this.sources.set(source, { version: source.version, readIn: this.runCount });\n if (this.linked) {\n source.observe(this);\n }\n }\n\n public refresh(): Outcome<T> {\n const current = this.outcome;\n if (hasSome(current) && !this.needsCheck && (this.linked || this.checkedAt === epoch)) {\n return current;\n }\n this.needsCheck = false;\n this.checkedAt = epoch;\n if (hasSome(current) && !this.sourceChanged()) {\n return current;\n }\n\n const { fn } = this;\n this.runCount++;\n this.deafTo = null;\n const outer = setTracker(this);\n const outerRunning = setRunning(this);\n let next: Outcome<T>;\n try {\n next = { threw: false, value: fn() };\n } catch (error) {\n next = { threw: true, value: error };\n }\n setTracker(outer);\n setRunning(outerRunning);\n this.dropUnread();\n if (!hasSome(current) || current.threw !== next.threw || !Object.is(current.value, next.value)) {\n this.version++;\n }\n this.outcome = next;\n return next;\n }\n\n protected detach(): void {\n for (const source of this.sources.keys()) {\n source.unobserve(this);\n }\n }\n\n private unlink(source: Source): void {\n this.sources.delete(source);\n source.unobserve(this);\n }\n\n private sourceChanged(): boolean {\n for (const [source, { version }] of this.sources) {\n source.refresh();\n if (source.version !== version) {\n return true;\n }\n }\n return false;\n }\n\n private dropUnread(): void {\n for (const [source, link] of this.sources) {\n if (link.readIn !== this.runCount) {\n this.unlink(source);\n }\n }\n }\n\n protected abstract onMarked(written: Source): void;\n}\n\nclass Computed<T> extends Computation<T> implements Source {\n public readonly observers = new Set<Computation<unknown>>();\n\n protected get linked(): boolean {\n return this.observers.size > 0;\n }\n\n public readonly read = (): T => {\n const outcome = this.refresh();\n tracker?.onRead(this);\n if (outcome.threw) {\n throw outcome.value;\n }\n return outcome.value;\n };\n\n public observe(observer: Computation<unknown>): void {\n if (this.observers.size === 0) {\n for (const source of this.sources.keys()) {\n source.observe(this);\n }\n }\n this.observers.add(observer);\n }\n\n public unobserve(observer: Computation<unknown>): void {\n if (this.observers.delete(observer) && this.observers.size === 0) {\n this.detach();\n }\n }\n\n protected onMarked(written: Source): void {\n this.needsCheck = true;\n for (const observer of this.observers) {\n observer.mark(written);\n }\n }\n}\n\n// stays linked until disposed; a mark queues it to run\nclass Effect extends Computation<void> {\n private disposed = false;\n\n protected get linked(): boolean {\n return !this.disposed;\n }\n\n public readonly dispose = (): void => {\n this.disposed = true;\n this.detach();\n this.sources.clear();\n };\n\n // runs it if what it read has changed, and throws what that run threw\n public run(): void {\n if (this.disposed) {\n return;\n }\n const before = this.outcome;\n const after = this.refresh();\n if (after !== before && after.threw) {\n throw after.value;\n }\n }\n\n // taken out of the queue without running: the next write queues it again\n public dequeue(): void {\n this.needsCheck = false;\n }\n\n protected onMarked(): void {\n if (!this.needsCheck) {\n this.needsCheck = true;\n queue.push(this);\n }\n }\n}\n\n/** Runs the queued effects now; what they write joins as the next wave. Every effect runs, the first error is rethrown. */\nconst flushSync = (): void => {\n if (flushing) {\n return;\n }\n flushing = true;\n let failure: Nullable<{ readonly error: unknown }> = null;\n try {\n let wave = 0;\n while (queue.length > 0) {\n wave++;\n if (wave > MAX_FLUSH_WAVES) {\n const dropped = queue;\n queue = [];\n dropped.forEach((effect) => effect.dequeue());\n throw new Error(\n `reelx: a cycle of effects, each writes a signal another one reads (${MAX_FLUSH_WAVES} waves)`,\n { cause: failure?.error }\n );\n }\n const effects = queue;\n queue = [];\n for (const effect of effects) {\n try {\n effect.run();\n } catch (error) {\n failure ??= { error };\n }\n }\n }\n } finally {\n flushing = false;\n }\n if (hasSome(failure)) {\n throw failure.error;\n }\n};\n\n// the node behind each reader, for writes and for `subscriberCount`\nconst nodes = new WeakMap<ReactiveValue<unknown>, Signal<unknown> | Computed<unknown>>();\n\nconst toReactiveValue = <T>(node: Signal<T> | Computed<T>): ReactiveValue<T> => {\n nodes.set(node.read, node);\n return node.read;\n};\n\n/** A value that effects and computeds re-read when it is written; a function is held as a value too. */\nexport const signal = <T>(initial: T, options: SignalOptions<T> = {}): ReactiveValue<T> =>\n toReactiveValue(new Signal(initial, options));\n\n/** A value derived from what `fn` reads, recomputed on read after one of them changes; what `fn` throws is rethrown until then. */\nexport const computed = <T>(fn: () => T): ReactiveValue<T> => toReactiveValue(new Computed(fn));\n\n/** Writes `value` to the signal `read` reads (a computed's reader is ignored); a value its `equals` finds unchanged changes nothing. */\nexport const write = <T>(read: ReactiveValue<T>, value: T): void => {\n const node = nodes.get(read);\n if (isInstanceOf(Signal, node)) {\n node.write(value);\n }\n};\n\n/** Runs `fn` now and again after every change of what it read. Returns `dispose`; creation that throws leaves nothing subscribed. */\nexport const effect = (fn: () => void): (() => void) => {\n const node = new Effect(fn);\n try {\n batch(() => {\n try {\n node.run();\n } catch (error) {\n // before the flush that ends the batch, so it never runs an effect whose creation threw\n node.dispose();\n throw error;\n }\n });\n } catch (error) {\n node.dispose();\n throw error;\n }\n return node.dispose;\n};\n\n/**\n * Calls `cb` with the value of `read` and the one before it: now, unless it is `undefined`, then\n * after every change `read` notifies. Returns `unsubscribe`.\n */\nexport const subscribe = <T>(read: ReactiveValue<T>, cb: (value: T, prevValue?: T) => void): (() => void) => {\n let last: T | undefined;\n let started = false;\n return effect(() => {\n const value = read();\n const previous = last;\n last = value;\n if (started || !Object.is(value, undefined)) {\n untracked(() => cb(value, previous));\n }\n started = true;\n });\n};\n\n/** Groups writes: effects run once, when the outermost `batch` ends, even if `fn` throws. */\nexport const batch = <T>(fn: () => T): T => {\n batchDepth++;\n try {\n return fn();\n } finally {\n if (--batchDepth === 0) {\n flushSync();\n }\n }\n};\n\n/** Runs `fn` without subscribing the running effect or computed to what it reads. */\nexport const untracked = <T>(fn: () => T): T => {\n const outer = setTracker(null);\n try {\n return fn();\n } finally {\n setTracker(outer);\n }\n};\n\n/** How many effects and computeds read `read` now: tests count them to prove that `dispose` released them. */\nexport const subscriberCount = (read: ReactiveValue<unknown>): number => nodes.get(read)?.observers.size ?? 0;\n"],"mappings":"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"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A value read reactively: a signal, a computed or any getter of them; an effect or computed that reads it follows it. */
|
|
2
|
+
declare type ReactiveValue<T> = () => T;
|
|
3
|
+
|
|
4
|
+
/** How many effects and computeds read `read` now: tests count them to prove that `dispose` released them. */
|
|
5
|
+
export declare const subscriberCount: (read: ReactiveValue<unknown>) => number;
|
|
6
|
+
|
|
7
|
+
export { }
|
package/dist/testing.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reely/signals",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Signals, computed values and effects: a push-pull graph with owners that release what a piece of work subscribed. No DOM.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/JsPowWow/reely.git"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./testing": {
|
|
26
|
+
"types": "./dist/testing.d.ts",
|
|
27
|
+
"import": "./dist/testing.js",
|
|
28
|
+
"default": "./dist/testing.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"!**/*.tsbuildinfo"
|
|
34
|
+
],
|
|
35
|
+
"nx": {
|
|
36
|
+
"tags": [
|
|
37
|
+
"scope:shared"
|
|
38
|
+
],
|
|
39
|
+
"projectType": "library",
|
|
40
|
+
"sourceRoot": "packages/signals/src",
|
|
41
|
+
"targets": {
|
|
42
|
+
"build": {
|
|
43
|
+
"executor": "nx:run-commands",
|
|
44
|
+
"options": {
|
|
45
|
+
"command": "vite build",
|
|
46
|
+
"cwd": "packages/signals"
|
|
47
|
+
},
|
|
48
|
+
"dependsOn": [
|
|
49
|
+
"^build"
|
|
50
|
+
],
|
|
51
|
+
"outputs": [
|
|
52
|
+
"{projectRoot}/dist"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"consumer": {
|
|
56
|
+
"executor": "nx:run-commands",
|
|
57
|
+
"options": {
|
|
58
|
+
"command": "node ../../scripts/consumer-check.mjs",
|
|
59
|
+
"cwd": "packages/signals"
|
|
60
|
+
},
|
|
61
|
+
"dependsOn": [
|
|
62
|
+
"build",
|
|
63
|
+
"^build"
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@reely/utils": "*"
|
|
70
|
+
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@reely/basics": "^0.1.0"
|
|
73
|
+
}
|
|
74
|
+
}
|