@pyreon/reactivity 0.1.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.
@@ -0,0 +1,136 @@
1
+ import { effect } from "../effect"
2
+ import { reconcile } from "../reconcile"
3
+ import { createStore, isStore } from "../store"
4
+
5
+ describe("createStore", () => {
6
+ test("reads primitive properties reactively", () => {
7
+ const state = createStore({ count: 0 })
8
+ const calls: number[] = []
9
+ effect(() => {
10
+ calls.push(state.count)
11
+ })
12
+ expect(calls).toEqual([0])
13
+ state.count = 1
14
+ expect(calls).toEqual([0, 1])
15
+ state.count = 1 // no-op (same value)
16
+ expect(calls).toEqual([0, 1])
17
+ })
18
+
19
+ test("deep reactive — nested object", () => {
20
+ const state = createStore({ user: { name: "Alice", age: 30 } })
21
+ const names: string[] = []
22
+ effect(() => {
23
+ names.push(state.user.name)
24
+ })
25
+ expect(names).toEqual(["Alice"])
26
+ state.user.name = "Bob"
27
+ expect(names).toEqual(["Alice", "Bob"])
28
+ })
29
+
30
+ test("deep reactive — nested change does NOT re-run parent-only effects", () => {
31
+ const state = createStore({ user: { name: "Alice", age: 30 } })
32
+ const userCalls: number[] = []
33
+ const nameCalls: string[] = []
34
+ effect(() => {
35
+ userCalls.push(1)
36
+ void state.user
37
+ }) // tracks user object
38
+ effect(() => {
39
+ nameCalls.push(state.user.name)
40
+ }) // tracks name only
41
+ expect(userCalls.length).toBe(1)
42
+ state.user.age = 31
43
+ // Only the age signal fires — user object didn't change, name didn't change
44
+ expect(nameCalls).toEqual(["Alice"]) // name effect didn't re-run
45
+ })
46
+
47
+ test("array — tracks length on push", () => {
48
+ const state = createStore({ items: [1, 2, 3] })
49
+ const lengths: number[] = []
50
+ effect(() => {
51
+ lengths.push(state.items.length)
52
+ })
53
+ expect(lengths).toEqual([3])
54
+ state.items.push(4)
55
+ expect(lengths).toEqual([3, 4])
56
+ })
57
+
58
+ test("array — tracks index access", () => {
59
+ const state = createStore({ items: ["a", "b"] })
60
+ const values: string[] = []
61
+ effect(() => {
62
+ values.push(state.items[0] as string)
63
+ })
64
+ expect(values).toEqual(["a"])
65
+ state.items[0] = "x"
66
+ expect(values).toEqual(["a", "x"])
67
+ state.items[1] = "y" // different index — should not re-run this effect
68
+ expect(values).toEqual(["a", "x"])
69
+ })
70
+
71
+ test("isStore identifies proxy", () => {
72
+ const raw = { x: 1 }
73
+ const store = createStore(raw)
74
+ expect(isStore(store)).toBe(true)
75
+ expect(isStore(raw)).toBe(false)
76
+ expect(isStore(null)).toBe(false)
77
+ expect(isStore(42)).toBe(false)
78
+ })
79
+
80
+ test("same raw object returns same proxy", () => {
81
+ const raw = { a: 1 }
82
+ const s1 = createStore(raw)
83
+ const s2 = createStore(raw)
84
+ expect(s1).toBe(s2)
85
+ })
86
+ })
87
+
88
+ describe("reconcile", () => {
89
+ test("updates only changed scalar properties", () => {
90
+ const state = createStore({ name: "Alice", age: 30 })
91
+ const nameCalls: string[] = []
92
+ const ageCalls: number[] = []
93
+ effect(() => {
94
+ nameCalls.push(state.name)
95
+ })
96
+ effect(() => {
97
+ ageCalls.push(state.age)
98
+ })
99
+ reconcile({ name: "Alice", age: 31 }, state)
100
+ expect(nameCalls).toEqual(["Alice"]) // unchanged — no re-run
101
+ expect(ageCalls).toEqual([30, 31]) // changed — re-ran
102
+ })
103
+
104
+ test("reconciles nested objects recursively", () => {
105
+ const state = createStore({ user: { name: "Alice", age: 30 } })
106
+ const nameCalls: string[] = []
107
+ effect(() => {
108
+ nameCalls.push(state.user.name)
109
+ })
110
+ reconcile({ user: { name: "Bob", age: 30 } }, state)
111
+ expect(nameCalls).toEqual(["Alice", "Bob"])
112
+ })
113
+
114
+ test("reconciles arrays by index", () => {
115
+ const state = createStore({ items: ["a", "b", "c"] })
116
+ const calls: string[][] = []
117
+ effect(() => {
118
+ calls.push([...state.items])
119
+ })
120
+ reconcile({ items: ["a", "X", "c"] }, state)
121
+ expect(state.items[1]).toBe("X")
122
+ expect(calls.length).toBe(2) // initial + after reconcile
123
+ })
124
+
125
+ test("trims excess array elements", () => {
126
+ const state = createStore({ items: [1, 2, 3, 4, 5] })
127
+ reconcile({ items: [1, 2] }, state)
128
+ expect(state.items.length).toBe(2)
129
+ })
130
+
131
+ test("removes deleted keys", () => {
132
+ const state = createStore({ a: 1, b: 2, c: 3 } as Record<string, number>)
133
+ reconcile({ a: 1, b: 2 }, state)
134
+ expect("c" in state).toBe(false)
135
+ })
136
+ })
@@ -0,0 +1,158 @@
1
+ import { batch } from "../batch"
2
+ import { effect, renderEffect } from "../effect"
3
+ import { signal } from "../signal"
4
+ import { runUntracked } from "../tracking"
5
+
6
+ describe("tracking", () => {
7
+ describe("notifySubscribers", () => {
8
+ test("multi-subscriber notification without batching (snapshot path)", () => {
9
+ const s = signal(0)
10
+ let runs1 = 0
11
+ let runs2 = 0
12
+
13
+ effect(() => {
14
+ s()
15
+ runs1++
16
+ })
17
+ effect(() => {
18
+ s()
19
+ runs2++
20
+ })
21
+
22
+ expect(runs1).toBe(1)
23
+ expect(runs2).toBe(1)
24
+
25
+ // Triggers non-batched multi-subscriber path (snapshot via [...subscribers])
26
+ s.set(1)
27
+ expect(runs1).toBe(2)
28
+ expect(runs2).toBe(2)
29
+ })
30
+
31
+ test("multi-subscriber notification during batching", () => {
32
+ const s = signal(0)
33
+ let runs1 = 0
34
+ let runs2 = 0
35
+
36
+ effect(() => {
37
+ s()
38
+ runs1++
39
+ })
40
+ effect(() => {
41
+ s()
42
+ runs2++
43
+ })
44
+
45
+ batch(() => {
46
+ s.set(1)
47
+ })
48
+
49
+ expect(runs1).toBe(2)
50
+ expect(runs2).toBe(2)
51
+ })
52
+
53
+ test("single subscriber batching path", () => {
54
+ const s = signal(0)
55
+ let runs = 0
56
+
57
+ effect(() => {
58
+ s()
59
+ runs++
60
+ })
61
+
62
+ batch(() => {
63
+ s.set(1)
64
+ })
65
+
66
+ expect(runs).toBe(2)
67
+ })
68
+ })
69
+
70
+ describe("runUntracked", () => {
71
+ test("signal reads inside runUntracked do not create dependencies", () => {
72
+ const s = signal(0)
73
+ let runs = 0
74
+
75
+ effect(() => {
76
+ runUntracked(() => s())
77
+ runs++
78
+ })
79
+
80
+ expect(runs).toBe(1)
81
+ s.set(1)
82
+ expect(runs).toBe(1) // not re-run
83
+ })
84
+
85
+ test("restores tracking context after runUntracked", () => {
86
+ const tracked = signal(0)
87
+ const untracked = signal(0)
88
+ let runs = 0
89
+
90
+ effect(() => {
91
+ tracked()
92
+ runUntracked(() => untracked())
93
+ runs++
94
+ })
95
+
96
+ expect(runs).toBe(1)
97
+
98
+ // tracked signal should still trigger re-run
99
+ tracked.set(1)
100
+ expect(runs).toBe(2)
101
+
102
+ // untracked signal should not
103
+ untracked.set(1)
104
+ expect(runs).toBe(2)
105
+ })
106
+ })
107
+
108
+ describe("trackSubscriber with depsCollector", () => {
109
+ test("renderEffect uses fast deps collector path", () => {
110
+ const s = signal(0)
111
+ let runs = 0
112
+
113
+ const dispose = renderEffect(() => {
114
+ s()
115
+ runs++
116
+ })
117
+
118
+ s.set(1)
119
+ expect(runs).toBe(2)
120
+
121
+ dispose()
122
+ s.set(2)
123
+ expect(runs).toBe(2)
124
+ })
125
+ })
126
+
127
+ describe("cleanupEffect", () => {
128
+ test("effect dynamically tracks/untracks deps on re-run", () => {
129
+ const cond = signal(true)
130
+ const a = signal(0)
131
+ const b = signal(0)
132
+ let runs = 0
133
+
134
+ effect(() => {
135
+ if (cond()) {
136
+ a()
137
+ } else {
138
+ b()
139
+ }
140
+ runs++
141
+ })
142
+
143
+ expect(runs).toBe(1)
144
+
145
+ a.set(1) // tracked
146
+ expect(runs).toBe(2)
147
+
148
+ cond.set(false) // switch branch
149
+ expect(runs).toBe(3)
150
+
151
+ a.set(2) // no longer tracked
152
+ expect(runs).toBe(3)
153
+
154
+ b.set(1) // now tracked
155
+ expect(runs).toBe(4)
156
+ })
157
+ })
158
+ })
@@ -0,0 +1,146 @@
1
+ import { signal } from "../signal"
2
+ import { watch } from "../watch"
3
+
4
+ describe("watch", () => {
5
+ test("calls callback when source changes", () => {
6
+ const s = signal(1)
7
+ const calls: [number, number | undefined][] = []
8
+
9
+ watch(
10
+ () => s(),
11
+ (newVal, oldVal) => {
12
+ calls.push([newVal, oldVal])
13
+ },
14
+ )
15
+
16
+ expect(calls.length).toBe(0) // not called on first run without immediate
17
+
18
+ s.set(2)
19
+ expect(calls).toEqual([[2, 1]])
20
+
21
+ s.set(3)
22
+ expect(calls).toEqual([
23
+ [2, 1],
24
+ [3, 2],
25
+ ])
26
+ })
27
+
28
+ test("immediate option calls callback on first run", () => {
29
+ const s = signal(1)
30
+ const calls: [number, number | undefined][] = []
31
+
32
+ watch(
33
+ () => s(),
34
+ (newVal, oldVal) => {
35
+ calls.push([newVal, oldVal])
36
+ },
37
+ { immediate: true },
38
+ )
39
+
40
+ expect(calls).toEqual([[1, undefined]])
41
+ })
42
+
43
+ test("stop function disposes the watcher", () => {
44
+ const s = signal(1)
45
+ let callCount = 0
46
+
47
+ const stop = watch(
48
+ () => s(),
49
+ () => {
50
+ callCount++
51
+ },
52
+ )
53
+
54
+ s.set(2)
55
+ expect(callCount).toBe(1)
56
+
57
+ stop()
58
+
59
+ s.set(3)
60
+ expect(callCount).toBe(1) // no more calls
61
+ })
62
+
63
+ test("cleanup function is called before each re-run", () => {
64
+ const s = signal(1)
65
+ const log: string[] = []
66
+
67
+ watch(
68
+ () => s(),
69
+ (newVal) => {
70
+ log.push(`run-${newVal}`)
71
+ return () => log.push(`cleanup-${newVal}`)
72
+ },
73
+ )
74
+
75
+ s.set(2)
76
+ expect(log).toEqual(["run-2"])
77
+
78
+ s.set(3)
79
+ expect(log).toEqual(["run-2", "cleanup-2", "run-3"])
80
+ })
81
+
82
+ test("cleanup function from immediate is called on next change", () => {
83
+ const s = signal(1)
84
+ const log: string[] = []
85
+
86
+ watch(
87
+ () => s(),
88
+ (newVal) => {
89
+ log.push(`run-${newVal}`)
90
+ return () => log.push(`cleanup-${newVal}`)
91
+ },
92
+ { immediate: true },
93
+ )
94
+
95
+ expect(log).toEqual(["run-1"])
96
+
97
+ s.set(2)
98
+ expect(log).toEqual(["run-1", "cleanup-1", "run-2"])
99
+ })
100
+
101
+ test("cleanup is called on stop", () => {
102
+ const s = signal(1)
103
+ const log: string[] = []
104
+
105
+ const stop = watch(
106
+ () => s(),
107
+ (newVal) => {
108
+ log.push(`run-${newVal}`)
109
+ return () => log.push(`cleanup-${newVal}`)
110
+ },
111
+ )
112
+
113
+ s.set(2)
114
+ expect(log).toEqual(["run-2"])
115
+
116
+ stop()
117
+ expect(log).toEqual(["run-2", "cleanup-2"])
118
+ })
119
+
120
+ test("callback returning non-function does not set cleanup", () => {
121
+ const s = signal(1)
122
+ let callCount = 0
123
+
124
+ watch(
125
+ () => s(),
126
+ () => {
127
+ callCount++
128
+ // returns void, not a function
129
+ },
130
+ )
131
+
132
+ s.set(2)
133
+ s.set(3)
134
+ expect(callCount).toBe(2)
135
+ })
136
+
137
+ test("stop without cleanup does not throw", () => {
138
+ const s = signal(1)
139
+ const stop = watch(
140
+ () => s(),
141
+ () => {},
142
+ )
143
+
144
+ stop() // no cleanup function was set, should not throw
145
+ })
146
+ })
@@ -0,0 +1,103 @@
1
+ // Global subscriber tracking context
2
+
3
+ import { enqueuePendingNotification, isBatching } from "./batch"
4
+
5
+ let activeEffect: (() => void) | null = null
6
+
7
+ // Tracks which subscriber sets each effect is registered in, so we can
8
+ // clean them up before a re-run (dynamic dependency tracking).
9
+ const effectDeps = new WeakMap<() => void, Set<Set<() => void>>>()
10
+
11
+ // Fast deps collector for renderEffect — avoids WeakMap overhead entirely.
12
+ // When set, trackSubscriber pushes subscriber sets here instead of effectDeps.
13
+ let _depsCollector: Set<() => void>[] | null = null
14
+
15
+ export function setDepsCollector(collector: Set<() => void>[] | null): void {
16
+ _depsCollector = collector
17
+ }
18
+
19
+ /**
20
+ * Subscriber host — any reactive source that can have downstream subscribers.
21
+ * Signals, computeds, and createSelector buckets all implement this interface.
22
+ * The Set is created lazily — only allocated when an effect actually tracks this source.
23
+ */
24
+ export interface SubscriberHost {
25
+ /** @internal subscriber set — null until first tracked by an effect */
26
+ _s: Set<() => void> | null
27
+ }
28
+
29
+ /**
30
+ * Register the active effect as a subscriber of the given reactive source.
31
+ * The subscriber Set is created lazily on the host — sources read only outside
32
+ * effects never allocate a Set.
33
+ */
34
+ export function trackSubscriber(host: SubscriberHost) {
35
+ if (activeEffect) {
36
+ if (!host._s) host._s = new Set()
37
+ const subscribers = host._s
38
+ subscribers.add(activeEffect)
39
+ if (_depsCollector) {
40
+ // Fast path: renderEffect stores deps inline, no WeakMap
41
+ _depsCollector.push(subscribers)
42
+ } else {
43
+ // Record this dep so we can remove it on cleanup
44
+ let deps = effectDeps.get(activeEffect)
45
+ if (!deps) {
46
+ deps = new Set()
47
+ effectDeps.set(activeEffect, deps)
48
+ }
49
+ deps.add(subscribers)
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Remove an effect from every subscriber set it was registered in,
56
+ * then clear its dep record. Call this before each re-run and on dispose.
57
+ */
58
+ export function cleanupEffect(fn: () => void): void {
59
+ const deps = effectDeps.get(fn)
60
+ if (deps) {
61
+ for (const sub of deps) sub.delete(fn)
62
+ deps.clear()
63
+ }
64
+ }
65
+
66
+ export function notifySubscribers(subscribers: Set<() => void>) {
67
+ if (subscribers.size === 0) return
68
+ // Single-subscriber fast path: avoid any iteration overhead.
69
+ if (subscribers.size === 1) {
70
+ const sub = subscribers.values().next().value as () => void
71
+ if (isBatching()) enqueuePendingNotification(sub)
72
+ else sub()
73
+ return
74
+ }
75
+ if (isBatching()) {
76
+ // Effects are queued not run inline — no re-entrancy risk, iterate the live Set directly.
77
+ for (const sub of subscribers) enqueuePendingNotification(sub)
78
+ } else {
79
+ // Effects run inline and may call cleanupEffect (removes) + trackSubscriber (re-adds).
80
+ // Snapshot first to prevent the iterator from visiting re-inserted entries → infinite loop.
81
+ for (const sub of [...subscribers]) sub()
82
+ }
83
+ }
84
+
85
+ export function withTracking<T>(fn: () => void, compute: () => T): T {
86
+ const prev = activeEffect
87
+ activeEffect = fn
88
+ try {
89
+ return compute()
90
+ } finally {
91
+ activeEffect = prev
92
+ }
93
+ }
94
+
95
+ export function runUntracked<T>(fn: () => T): T {
96
+ const prev = activeEffect
97
+ activeEffect = null
98
+ try {
99
+ return fn()
100
+ } finally {
101
+ activeEffect = prev
102
+ }
103
+ }
package/src/watch.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { effect } from "./effect"
2
+
3
+ export interface WatchOptions {
4
+ /** If true, call the callback immediately with the current value on setup. Default: false. */
5
+ immediate?: boolean
6
+ }
7
+
8
+ /**
9
+ * Watch a reactive source and run a callback whenever it changes.
10
+ *
11
+ * Returns a stop function that disposes the watcher.
12
+ *
13
+ * The callback receives (newValue, oldValue). On the first call (when
14
+ * `immediate` is true) oldValue is `undefined`.
15
+ *
16
+ * The callback may return a cleanup function that is called before each
17
+ * re-run and on stop — useful for cancelling async work.
18
+ *
19
+ * @example
20
+ * const stop = watch(
21
+ * () => userId(),
22
+ * async (id, prev) => {
23
+ * const data = await fetch(`/api/user/${id}`)
24
+ * setUser(await data.json())
25
+ * },
26
+ * )
27
+ * // Later: stop()
28
+ */
29
+ export function watch<T>(
30
+ source: () => T,
31
+ // biome-ignore lint/suspicious/noConfusingVoidType: void is intentional — callers may return void
32
+ callback: (newVal: T, oldVal: T | undefined) => void | (() => void),
33
+ opts: WatchOptions = {},
34
+ ): () => void {
35
+ let oldVal: T | undefined
36
+ let isFirst = true
37
+ let cleanupFn: (() => void) | undefined
38
+
39
+ const e = effect(() => {
40
+ const newVal = source()
41
+
42
+ if (isFirst) {
43
+ isFirst = false
44
+ oldVal = newVal
45
+ if (opts.immediate) {
46
+ const result = callback(newVal, undefined)
47
+ if (typeof result === "function") cleanupFn = result
48
+ }
49
+ return
50
+ }
51
+
52
+ if (cleanupFn) {
53
+ cleanupFn()
54
+ cleanupFn = undefined
55
+ }
56
+
57
+ const result = callback(newVal, oldVal)
58
+ if (typeof result === "function") cleanupFn = result
59
+ oldVal = newVal
60
+ })
61
+
62
+ return () => {
63
+ e.dispose()
64
+ if (cleanupFn) {
65
+ cleanupFn()
66
+ cleanupFn = undefined
67
+ }
68
+ }
69
+ }