@pithyjs/signals 0.1.0-beta.0 → 0.1.0-beta.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/LICENSE CHANGED
@@ -19,3 +19,30 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ---
24
+
25
+ The dependency graph in src/signal.ts is adapted from alien-signals
26
+ (https://github.com/stackblitz/alien-signals), under the following license:
27
+
28
+ MIT License
29
+
30
+ Copyright (c) 2024-present Johnson Chu
31
+
32
+ Permission is hereby granted, free of charge, to any person obtaining a copy
33
+ of this software and associated documentation files (the "Software"), to deal
34
+ in the Software without restriction, including without limitation the rights
35
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
36
+ copies of the Software, and to permit persons to whom the Software is
37
+ furnished to do so, subject to the following conditions:
38
+
39
+ The above copyright notice and this permission notice shall be included in all
40
+ copies or substantial portions of the Software.
41
+
42
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
43
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
44
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
45
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
46
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
47
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
48
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pithyjs/signals
2
2
 
3
- Fine-grained reactive signal system for the PithyJS framework. Signals provide O(1) reactive state with automatic dependency tracking — no virtual DOM, no re-renders, only the exact DOM nodes that depend on a signal update.
3
+ Fine-grained reactive signal system for the PithyJS framework. Signals provide reactive state with automatic dependency tracking — no virtual DOM, no re-renders, only the exact DOM nodes that depend on a signal update.
4
4
 
5
5
  ## Installation
6
6
 
@@ -19,7 +19,7 @@ yarn add @pithyjs/signals
19
19
  <!-- @codex:end -->
20
20
 
21
21
  ```typescript
22
- import { signal, computed, effect, batch, onCleanup, isSignal } from '@pithyjs/signals';
22
+ import { signal, computed, effect, batch, untrack, onCleanup, isSignal } from '@pithyjs/signals';
23
23
  ```
24
24
 
25
25
  ## Quick Start
@@ -40,14 +40,78 @@ count(); // → 1
40
40
  <!-- @codex:auto api="pithy.feature.signals" format="table" -->
41
41
  | API | Signature | Stability | Description |
42
42
  | --- | --- | --- | --- |
43
- | isSignal | `(value: unknown) => value is Signal<any>` | stable | - |
43
+ | isSignal | `(value: unknown) => value is ReadableSignal<unknown>` | stable | - |
44
44
  | onCleanup | `(fn: () => void) => void` | stable | - |
45
45
  | effect | `(fn: () => void) => () => void` | stable | - |
46
46
  | batch | `(fn: () => void) => void` | stable | - |
47
+ | untrack | `<T>(fn: () => T) => T` | stable | - |
48
+ | createRoot | `<T>(fn: (dispose: () => void) => T) => T` | stable | - |
47
49
  | signal | `<T>(initial: T) => Signal<T>` | stable | - |
48
- | computed | `<T>(compute: () => T) => () => T` | stable | - |
50
+ | computed | `<T>(compute: () => T) => ReadableSignal<T>` | stable | - |
49
51
  <!-- @codex:end -->
50
52
 
53
+ ### How a computed updates
54
+
55
+ A write marks the computeds downstream of it and runs the effects they reach,
56
+ once each, before `set()` returns. A computed recomputes when something reads
57
+ it, and only if one of its sources changed, so every effect sees values that
58
+ belong together: a diamond reads `4/3`, never `4/2`. A computed nobody reads does
59
+ not recompute. It computes once when created, and it has no `set()`.
60
+
61
+ Inside a `batch()` body, a computed read is up to date with the values written
62
+ so far. A compute that throws there returns its last value and stays
63
+ out of date; its next reader computes it on the final values and reports the
64
+ error if it still fails. The full rules are in [`docs/signals/semantics.md`](../../docs/signals/semantics.md).
65
+
66
+ ### A computed must be pure
67
+
68
+ A `computed()`'s compute, and any `onCleanup` it registers, must not write a
69
+ signal or create an effect. A compute runs while an effect is reading it, so a
70
+ write from inside it changes state under that effect. In development, the first
71
+ such write or effect from each computed logs a warning; move the work into an
72
+ `effect()` that reads the computed.
73
+
74
+ ### When a subscriber throws
75
+
76
+ - A `set()`, or the flush at the end of the outermost `batch()`, notifies every
77
+ subscriber and listener, then rethrows the first failure it collected. A write
78
+ made from inside an effect behaves the same way.
79
+ - A `computed()` whose compute throws keeps its last value, and the write or
80
+ batch flush that made it recompute reports the error once its effects have
81
+ run. Inside a batch body the error is not reported there: the computed stays
82
+ out of date, and its next reader reports on the values the batch leaves.
83
+ Reading a computed inside a write, an effect or a batch never throws. A read
84
+ outside all three throws the error, and so does every later one until a write
85
+ lets the compute succeed. A computed whose first compute throws is disposed,
86
+ and `computed()` rethrows; so is one whose first compute fails through a
87
+ computed it reads, outside any write, effect or batch.
88
+ - An effect whose first run throws makes `effect()` throw, and so does one whose
89
+ first run reads a failing computed outside any write, effect or batch. The
90
+ caller then gets no disposer: with no owner, the effect is disposed first;
91
+ under an owner (`createRoot()` or another effect), it stays alive and owned,
92
+ and runs again on the next write to what it read. An effect created inside a
93
+ `batch()` body first runs when the batch ends, and the batch reports its
94
+ failure.
95
+ - Two computeds that read each other are not detected as a cycle: each reads
96
+ the other's last value, so the pair can disagree until a later write.
97
+ - The exception is a write to a signal that is still notifying — an effect writing
98
+ a signal it reads, or a reactive cycle. That notification stops at the failure
99
+ and rethrows the first one it collected, and so does any notification it was
100
+ started from, up to the outermost one, which carries on. A `batch()` inside an
101
+ effect that a flush is running does the same.
102
+ - A reactive cycle whose effects let the error propagate ends in a stack overflow.
103
+ One whose effects catch their own errors hangs
104
+ ([BUG-049](../../docs/BUGS.md)).
105
+ - A write that never settles can hang where a throwing subscriber used to end it:
106
+ a self-writing cycle through `batch()` beside a subscriber that throws, or a
107
+ chain deeper than the stack with several writers at each level
108
+ ([BUG-050](../../docs/BUGS.md)).
109
+ - If a batch body throws, its error is the one reported, even when the flush
110
+ fails too.
111
+ - A `subscribe()` listener that throws on its first, immediate call is not kept.
112
+ One that throws on a later write stays subscribed and rethrows on every write
113
+ until it is unsubscribed.
114
+
51
115
  ## Examples
52
116
 
53
117
  <!-- @codex:auto examples="pithy.feature.signals" -->
@@ -142,6 +206,29 @@ isSignal(42); // → false
142
206
  isSignal(() => {}); // → false
143
207
  isSignal(null); // → false
144
208
  ```
209
+
210
+ **unsubscribes from a dependency it stopped reading**
211
+
212
+ ```typescript
213
+ const useB = signal(true);
214
+ const b = signal(2);
215
+
216
+ let runs = 0;
217
+ const dispose = effect(() => {
218
+ runs += 1;
219
+ if (useB()) b();
220
+ });
221
+
222
+ useB.set(false);
223
+ const afterSwitch = runs;
224
+
225
+ b.set(99);
226
+ expect(runs, 'live effect re-ran for a dependency it stopped reading').toBe(
227
+ afterSwitch
228
+ );
229
+
230
+ dispose();
231
+ ```
145
232
  <!-- @codex:end -->
146
233
 
147
234
  ## Testing
@@ -153,6 +240,8 @@ isSignal(null); // → false
153
240
  | onCleanup | ✓ | - | - | Missing integration |
154
241
  | effect | ✓ | - | - | Missing integration |
155
242
  | batch | ✓ | - | - | Missing integration |
243
+ | untrack | ✓ | - | - | Missing integration |
244
+ | createRoot | ✓ | - | - | Missing integration |
156
245
  | signal | ✓ | - | - | Missing integration |
157
246
  | computed | ✓ | - | - | Missing integration |
158
247
  <!-- @codex:end -->
@@ -0,0 +1,29 @@
1
+ import { EffectFn } from './types';
2
+ declare global {
3
+ const __PITHY_TEST__: boolean | undefined;
4
+ }
5
+ export interface ContextReading {
6
+ effect: object | null;
7
+ owner: object | null;
8
+ tracking: boolean;
9
+ suppression: number;
10
+ batching: boolean;
11
+ pending: number;
12
+ notifying: number;
13
+ flushing: number;
14
+ }
15
+ export interface Introspection {
16
+ context: () => ContextReading;
17
+ cleanupStack: (owner: object) => readonly EffectFn[] | null | undefined;
18
+ subscribers: (node: object) => readonly object[];
19
+ links: (node: object) => number;
20
+ dependencies: (node: object) => readonly object[];
21
+ disposed: (node: object) => boolean;
22
+ }
23
+ export declare const nodesOf: WeakMap<object, object>;
24
+ export declare const effectWrappers: WeakMap<EffectFn, object>;
25
+ export declare const rootOwners: WeakMap<EffectFn, object>;
26
+ export declare const disposerCalled: WeakSet<object>;
27
+ export declare function installIntrospection(source: Introspection): void;
28
+ export declare function introspection(): Introspection;
29
+ //# sourceMappingURL=internals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internals.d.ts","sourceRoot":"","sources":["../src/internals.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,CAAC,MAAM,CAAC;IACb,MAAM,cAAc,EAAE,OAAO,GAAG,SAAS,CAAC;CAC3C;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,cAAc,CAAC;IAC9B,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,SAAS,QAAQ,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC;IACxE,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,CAAC;IACjD,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAChC,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,CAAC;IAClD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;CACrC;AAED,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,MAAM,CAAiB,CAAC;AAE9D,eAAO,MAAM,cAAc,EAAE,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAiB,CAAC;AAEvE,eAAO,MAAM,UAAU,EAAE,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAiB,CAAC;AAEnE,eAAO,MAAM,cAAc,EAAE,OAAO,CAAC,MAAM,CAAiB,CAAC;AAI7D,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAEhE;AAED,wBAAgB,aAAa,IAAI,aAAa,CAO7C"}
@@ -0,0 +1,15 @@
1
+ export const nodesOf = new WeakMap();
2
+ export const effectWrappers = new WeakMap();
3
+ export const rootOwners = new WeakMap();
4
+ export const disposerCalled = new WeakSet();
5
+ let installed = null;
6
+ export function installIntrospection(source) {
7
+ installed = source;
8
+ }
9
+ export function introspection() {
10
+ if (!installed) {
11
+ throw new Error('[PithyJS] Signal introspection is not installed. Define `__PITHY_TEST__` as `true` in the vitest config that needs it, as `packages/signals/vitest.config.ts` does.');
12
+ }
13
+ return installed;
14
+ }
15
+ //# sourceMappingURL=internals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internals.js","sourceRoot":"","sources":["../src/internals.ts"],"names":[],"mappings":"AA0BA,MAAM,CAAC,MAAM,OAAO,GAA4B,IAAI,OAAO,EAAE,CAAC;AAE9D,MAAM,CAAC,MAAM,cAAc,GAA8B,IAAI,OAAO,EAAE,CAAC;AAEvE,MAAM,CAAC,MAAM,UAAU,GAA8B,IAAI,OAAO,EAAE,CAAC;AAEnE,MAAM,CAAC,MAAM,cAAc,GAAoB,IAAI,OAAO,EAAE,CAAC;AAE7D,IAAI,SAAS,GAAyB,IAAI,CAAC;AAE3C,MAAM,UAAU,oBAAoB,CAAC,MAAqB;IACxD,SAAS,GAAG,MAAM,CAAC;AACrB,CAAC;AAED,MAAM,UAAU,aAAa;IAC3B,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,qKAAqK,CACtK,CAAC;IACJ,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
package/dist/signal.d.ts CHANGED
@@ -5,17 +5,20 @@
5
5
  "category": "feature"
6
6
  }
7
7
  */
8
- import { Signal } from './types';
8
+ import { ReadableSignal, Signal } from './types';
9
9
  /**
10
- * Checks if a value is a Signal at runtime.
10
+ * Checks if a value is a signal or a computed at runtime.
11
11
  * @param value - The value to check.
12
- * @returns True if the value is a Signal.
12
+ * @returns True if the value is a signal or a computed.
13
13
  */
14
- /** @codexApi {"parent":"pithy.feature.signals","name":"isSignal","stability":"stable","signature":"(value: unknown) => value is Signal<any>"} */
15
- export declare function isSignal(value: unknown): value is Signal<unknown>;
14
+ /** @codexApi {"parent":"pithy.feature.signals","name":"isSignal","stability":"stable","signature":"(value: unknown) => value is ReadableSignal<unknown>"} */
15
+ export declare function isSignal(value: unknown): value is ReadableSignal<unknown>;
16
16
  /**
17
- * Registers a cleanup callback that will be executed
18
- * before the current effect is re-run.
17
+ * Registers a cleanup callback, executed once — before the current effect's
18
+ * next re-run, or when it is disposed, whichever comes first. Effects and
19
+ * computeds created during a run are
20
+ * owned by that run and torn down on the same schedule; use `createRoot` for
21
+ * work that must outlive the run that built it.
19
22
  *
20
23
  * @param fn - The cleanup function.
21
24
  */
@@ -32,12 +35,35 @@ export declare function onCleanup(fn: () => void): void;
32
35
  export declare function effect(fn: () => void): () => void;
33
36
  /**
34
37
  * Batches multiple signal updates together.
35
- * Effects triggered during the batch are deferred and executed once after the batch.
38
+ * Batches nest: effects triggered inside are deferred and run once, after the outermost batch.
36
39
  *
37
40
  * @param fn - The function performing multiple signal updates.
38
41
  */
39
42
  /** @codexApi {"parent":"pithy.feature.signals","name":"batch","stability":"stable","signature":"(fn: () => void) => void"} */
40
43
  export declare function batch(fn: () => void): void;
44
+ /**
45
+ * Reads signals without subscribing the enclosing effect to them.
46
+ *
47
+ * @param fn - The function to run untracked.
48
+ * @returns Whatever `fn` returns.
49
+ */
50
+ /** @codexApi {"parent":"pithy.feature.signals","name":"untrack","stability":"stable","signature":"<T>(fn: () => T) => T"} */
51
+ export declare function untrack<T>(fn: () => T): T;
52
+ /**
53
+ * Creates an ownership root: effects and computeds created inside `fn` are owned
54
+ * by the root rather than by any enclosing effect, so an enclosing re-run does
55
+ * not dispose them. Disposing the root disposes everything it owns — but a root
56
+ * created inside another root is independent: hold its disposer, because the
57
+ * outer root will not reach it.
58
+ *
59
+ * Tracking is untouched — a signal read inside `fn` still subscribes whatever
60
+ * effect is current.
61
+ *
62
+ * @param fn - Receives the root's disposer.
63
+ * @returns Whatever `fn` returns.
64
+ */
65
+ /** @codexApi {"parent":"pithy.feature.signals","name":"createRoot","stability":"stable","signature":"<T>(fn: (dispose: () => void) => T) => T"} */
66
+ export declare function createRoot<T>(fn: (dispose: () => void) => T): T;
41
67
  /**
42
68
  * Creates a signal: a reactive function holding a value and supporting subscriptions.
43
69
  *
@@ -47,12 +73,12 @@ export declare function batch(fn: () => void): void;
47
73
  /** @codexApi {"parent":"pithy.feature.signals","name":"signal","stability":"stable","signature":"<T>(initial: T) => Signal<T>"} */
48
74
  export declare function signal<T>(initial: T): Signal<T>;
49
75
  /**
50
- * Creates a derived signal (computed value) that automatically
51
- * recalculates when its dependencies change.
76
+ * Creates a derived signal (computed value). It computes once when created,
77
+ * then recomputes when it is read after one of its sources changed.
52
78
  *
53
- * @param compute - A function returning the computed value.
54
- * @returns A readonly function returning the latest computed value.
79
+ * @param compute - A function returning the computed value. It must not write signals or create effects.
80
+ * @returns A read-only signal returning the latest computed value.
55
81
  */
56
- /** @codexApi {"parent":"pithy.feature.signals","name":"computed","stability":"stable","signature":"<T>(compute: () => T) => () => T"} */
57
- export declare function computed<T>(compute: () => T): () => T;
82
+ /** @codexApi {"parent":"pithy.feature.signals","name":"computed","stability":"stable","signature":"<T>(compute: () => T) => ReadableSignal<T>"} */
83
+ export declare function computed<T>(compute: () => T): ReadableSignal<T>;
58
84
  //# sourceMappingURL=signal.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"signal.d.ts","sourceRoot":"","sources":["../src/signal.ts"],"names":[],"mappings":"AAAA;;;;;;EAME;AAEF,OAAO,EAAE,MAAM,EAAiB,MAAM,SAAS,CAAC;AAmChD;;;;GAIG;AACH,iJAAiJ;AACjJ,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,OAAO,CAAC,CAKjE;AAiBD;;;;;GAKG;AACH,kIAAkI;AAClI,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAK9C;AAED;;;;;;GAMG;AACH,qIAAqI;AACrI,wBAAgB,MAAM,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAajD;AAED;;;;;GAKG;AACH,8HAA8H;AAC9H,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAa1C;AAED;;;;;GAKG;AACH,mIAAmI;AACnI,wBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAoE/C;AAED;;;;;;GAMG;AACH,yIAAyI;AACzI,wBAAgB,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,CAmBrD"}
1
+ {"version":3,"file":"signal.d.ts","sourceRoot":"","sources":["../src/signal.ts"],"names":[],"mappings":"AAAA;;;;;;EAME;AAIF,OAAO,EAAE,cAAc,EAAE,MAAM,EAAiB,MAAM,SAAS,CAAC;AA0kBhE;;;;GAIG;AACH,6JAA6J;AAC7J,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAAC,OAAO,CAAC,CAKzE;AAiBD;;;;;;;;GAQG;AACH,kIAAkI;AAClI,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAI9C;AAED;;;;;;GAMG;AACH,qIAAqI;AACrI,wBAAgB,MAAM,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAkFjD;AA8CD;;;;;GAKG;AACH,8HAA8H;AAC9H,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAiD1C;AAED;;;;;GAKG;AACH,6HAA6H;AAC7H,wBAAgB,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAQzC;AAED;;;;;;;;;;;;GAYG;AACH,mJAAmJ;AACnJ,wBAAgB,UAAU,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,IAAI,KAAK,CAAC,GAAG,CAAC,CA2B/D;AA+CD;;;;;GAKG;AACH,mIAAmI;AACnI,wBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAgE/C;AAED;;;;;;GAMG;AACH,mJAAmJ;AACnJ,wBAAgB,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAkH/D"}