@zakkster/lite-signal 1.0.4 → 1.0.6
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 +28 -0
- package/Signal.d.ts +34 -0
- package/Signal.js +7 -1
- package/Watch.js +72 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -448,6 +448,34 @@ npm run verify # test + test:gc + a sanity bench
|
|
|
448
448
|
|
|
449
449
|
---
|
|
450
450
|
|
|
451
|
+
## Performance Trade-offs & Topology Scaling
|
|
452
|
+
|
|
453
|
+
`lite-signal` was built with a strict mandate: **absolute zero garbage collection**. By packing the dependency graph into a flat, pre-allocated memory arena, we eliminate the Scavenger GC pauses that plague 120fps Canvas/WebGL loops.
|
|
454
|
+
|
|
455
|
+
However, flat arrays come with a mathematical trade-off. While memory allocation is $O(1)$, modifying a flat array during dynamic dependency churn requires $O(N)$ linear scans.
|
|
456
|
+
|
|
457
|
+
**Andrii Volynets** (author of the phenomenal [Alien Signals](https://github.com/stackblitz/alien-signals)) generously ran `lite-signal` through his advanced topology matrix. The results clearly highlight where the zero-GC flat-array architecture excels, and where pointer-based graphs (like Alien/Reflex) take the lead:
|
|
458
|
+
|
|
459
|
+
#### 1. Stable Topologies (Fan-in / Fan-out / Broadcast)
|
|
460
|
+
In stable environments (typical of game engines, particle systems, and visualizers), `lite-signal` is blisteringly fast and maintains a near-zero allocation profile, keeping frame times perfectly flat.
|
|
461
|
+
|
|
462
|
+
#### 2. Dynamic Topologies (Web Apps / Layered DAGs)
|
|
463
|
+
In highly chaotic graphs with branch switching, selective reads, and wide dense churn (typical of large DOM-based web frameworks like Vue or React), the $O(N)$ edge traversal cost of flat arrays becomes the dominant bottleneck.
|
|
464
|
+
|
|
465
|
+
*Andrii's benchmark results for dynamic topologies:*
|
|
466
|
+
| Scenario | alien-signals | reflex | lite-signal |
|
|
467
|
+
| :--- | :--- | :--- | :--- |
|
|
468
|
+
| **1000x12 (4 sources, dynamic)** | 184ms | 194ms | 2031ms |
|
|
469
|
+
| **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
|
|
470
|
+
| **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
|
|
471
|
+
|
|
472
|
+
**The Takeaway:** "Zero-GC" and "topology scalability" are orthogonal dimensions. If you are building a DOM framework with massive dynamic `v-if` churn, use Alien Signals. If you are building a 120fps Canvas game with a stable scene graph where any GC pause is a dropped frame, use `lite-signal`.
|
|
473
|
+
|
|
474
|
+
### Roadmap: v1.1
|
|
475
|
+
We are actively working on a v1.1 architectural update to address this topology degradation while maintaining the zero-GC contract. By moving to a version-stamped dependency reconciliation pass (`lastSeenInEval`) with a pre-allocated scratch buffer, we expect to drop dynamic read costs to $O(1)$ unconditionally.
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
451
479
|
## What this is not
|
|
452
480
|
|
|
453
481
|
- **A virtual DOM, JSX runtime, or rendering library.** It's the substrate. Plug it under whatever rendering layer you like.
|
package/Signal.d.ts
CHANGED
|
@@ -165,3 +165,37 @@ export function batch<T>(fn: () => T): T;
|
|
|
165
165
|
export function untrack<T>(fn: () => T): T;
|
|
166
166
|
export function onCleanup(fn: () => void): void;
|
|
167
167
|
export function stats(): RegistryStats;
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Configuration options for the watch utility.
|
|
172
|
+
*/
|
|
173
|
+
export interface WatchOptions {
|
|
174
|
+
/** * If true, fires the callback immediately upon registration
|
|
175
|
+
* with `oldValue` set to `undefined`.
|
|
176
|
+
*/
|
|
177
|
+
immediate?: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Track a reactive source and run a callback whenever its evaluated value changes.
|
|
182
|
+
*
|
|
183
|
+
* Models Vue's `watch(source, callback)` and MobX's `reaction(predicate, effect)`.
|
|
184
|
+
* Internal reads inside the callback are untracked — they do not create reactive
|
|
185
|
+
* dependencies.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* const count = signal(0);
|
|
189
|
+
* const stop = watch(() => count() * 2, (next, prev) => {
|
|
190
|
+
* console.log(`Doubled count changed: ${prev} -> ${next}`);
|
|
191
|
+
* });
|
|
192
|
+
* * @param source A function that reads reactive values (e.g., a signal/computed getter).
|
|
193
|
+
* @param callback Fired when the source's value changes. Receives the new and previous values.
|
|
194
|
+
* @param options Optional configuration (e.g., `{ immediate: true }`).
|
|
195
|
+
* @returns Dispose function — call to stop watching and release the effect.
|
|
196
|
+
*/
|
|
197
|
+
export function watch<T>(
|
|
198
|
+
source: () => T,
|
|
199
|
+
callback: (newValue: T, oldValue: T | undefined) => void,
|
|
200
|
+
options?: WatchOptions
|
|
201
|
+
): () => void;
|
package/Signal.js
CHANGED
|
@@ -1004,4 +1004,10 @@ export function onCleanup(fn) {
|
|
|
1004
1004
|
/** @type {Registry["stats"]} */
|
|
1005
1005
|
export function stats() {
|
|
1006
1006
|
return defaultRegistry.stats();
|
|
1007
|
-
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Re-export of the user-land watch utility.
|
|
1011
|
+
* @see {@link watch} in Watch.js for full implementation details.
|
|
1012
|
+
*/
|
|
1013
|
+
export {watch} from "./Watch.js"
|
package/Watch.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { effect, untrack } from "./Signal.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sentinel for "first run" — distinguishes a legitimate `undefined` source value
|
|
5
|
+
* from the uninitialized state. Using `Symbol` instead of `undefined` ensures a
|
|
6
|
+
* source like `signal(undefined)` correctly fires `callback(undefined, undefined)`
|
|
7
|
+
* on first change rather than being treated as never-changed.
|
|
8
|
+
* @private
|
|
9
|
+
*/
|
|
10
|
+
const UNINITIALIZED = Symbol("watch.uninitialized");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Track a reactive source and run a callback whenever its value changes.
|
|
14
|
+
*
|
|
15
|
+
* Models Vue's `watch(source, callback)` and MobX's `reaction(predicate, effect)`.
|
|
16
|
+
* The callback is invoked with `(newValue, oldValue)`. Internal reads inside the
|
|
17
|
+
* callback are untracked — they don't create reactive dependencies — so a callback
|
|
18
|
+
* that reads other signals to perform a side-effect won't re-fire when those
|
|
19
|
+
* unrelated signals change.
|
|
20
|
+
*
|
|
21
|
+
* Disposing the returned function detaches the underlying effect and stops the
|
|
22
|
+
* watcher.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const count = signal(0);
|
|
26
|
+
* const stop = watch(count, (next, prev) => {
|
|
27
|
+
* console.log(`count changed: ${prev} -> ${next}`);
|
|
28
|
+
* });
|
|
29
|
+
* count.set(1); // logs: "count changed: 0 -> 1"
|
|
30
|
+
* count.set(2); // logs: "count changed: 1 -> 2"
|
|
31
|
+
* stop();
|
|
32
|
+
* count.set(3); // no log
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // Immediate fires the callback once on registration with `oldValue = undefined`
|
|
36
|
+
* watch(count, (next, prev) => console.log(next), { immediate: true });
|
|
37
|
+
*
|
|
38
|
+
* @param {() => T} source A function that reads reactive values (typically a
|
|
39
|
+
* signal/computed getter, or a closure combining several).
|
|
40
|
+
* @param {(newValue: T, oldValue: T | undefined) => void} callback
|
|
41
|
+
* Called when the source's value changes. Receives the
|
|
42
|
+
* new and previous values. Internal reads are untracked.
|
|
43
|
+
* @param {{ immediate?: boolean }} [options]
|
|
44
|
+
* `immediate: true` runs the callback once on registration
|
|
45
|
+
* with `oldValue = undefined`. Defaults to false.
|
|
46
|
+
* @returns {() => void} Dispose function — call to stop watching.
|
|
47
|
+
* @template T
|
|
48
|
+
*/
|
|
49
|
+
export function watch(source, callback, options) {
|
|
50
|
+
const immediate = options !== undefined && options.immediate === true;
|
|
51
|
+
let oldValue = UNINITIALIZED;
|
|
52
|
+
|
|
53
|
+
return effect(() => {
|
|
54
|
+
// Track the source — this read registers the dependency.
|
|
55
|
+
const newValue = source();
|
|
56
|
+
|
|
57
|
+
// Invoke the callback without registering further dependencies.
|
|
58
|
+
untrack(() => {
|
|
59
|
+
if (oldValue === UNINITIALIZED) {
|
|
60
|
+
if (immediate) callback(newValue, undefined);
|
|
61
|
+
} else if (!Object.is(newValue, oldValue)) {
|
|
62
|
+
// Guard for raw inline getters: the effect re-runs whenever any read
|
|
63
|
+
// dep changes, but the projected source value may be unchanged. Vue's
|
|
64
|
+
// `watch` and MobX's `reaction` both short-circuit here. Wrapping the
|
|
65
|
+
// source in a `computed` would also suppress this via the equality
|
|
66
|
+
// check inside computed itself; the guard makes that wrapping optional.
|
|
67
|
+
callback(newValue, oldValue);
|
|
68
|
+
}
|
|
69
|
+
oldValue = newValue;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Zero-GC reactive graph. Monomorphic object pool, versioned push-pull propagation, 32-bit modular versioning. Built for hot paths and long-running processes.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"Signal.js",
|
|
20
20
|
"Signal.d.ts",
|
|
21
|
+
"Watch.js",
|
|
21
22
|
"README.md",
|
|
22
23
|
"llms.txt",
|
|
23
24
|
"LICENSE.txt"
|