@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,111 @@
1
+ import { Cell, cell } from "../cell"
2
+
3
+ describe("Cell", () => {
4
+ test("stores and reads initial value", () => {
5
+ const c = cell(42)
6
+ expect(c.peek()).toBe(42)
7
+ })
8
+
9
+ test("set() updates value", () => {
10
+ const c = cell("hello")
11
+ c.set("world")
12
+ expect(c.peek()).toBe("world")
13
+ })
14
+
15
+ test("set() skips when value is the same (Object.is)", () => {
16
+ const c = cell(1)
17
+ let calls = 0
18
+ c.listen(() => calls++)
19
+ c.set(1)
20
+ expect(calls).toBe(0)
21
+ })
22
+
23
+ test("update() applies function to current value", () => {
24
+ const c = cell(10)
25
+ c.update((v) => v + 5)
26
+ expect(c.peek()).toBe(15)
27
+ })
28
+
29
+ test("listen() fires on set()", () => {
30
+ const c = cell("a")
31
+ let fired = false
32
+ c.listen(() => {
33
+ fired = true
34
+ })
35
+ c.set("b")
36
+ expect(fired).toBe(true)
37
+ })
38
+
39
+ test("listen() single-listener fast path (no Set allocated)", () => {
40
+ const c = cell(0)
41
+ let count = 0
42
+ c.listen(() => count++)
43
+ // Should use _l fast path, not _s Set
44
+ expect(c._s).toBeNull()
45
+ expect(c._l).not.toBeNull()
46
+ c.set(1)
47
+ expect(count).toBe(1)
48
+ })
49
+
50
+ test("listen() promotes to Set with multiple listeners", () => {
51
+ const c = cell(0)
52
+ let a = 0
53
+ let b = 0
54
+ c.listen(() => a++)
55
+ c.listen(() => b++)
56
+ expect(c._s).not.toBeNull()
57
+ expect(c._l).toBeNull()
58
+ c.set(1)
59
+ expect(a).toBe(1)
60
+ expect(b).toBe(1)
61
+ })
62
+
63
+ test("subscribe() returns working unsubscribe (single listener)", () => {
64
+ const c = cell(0)
65
+ let count = 0
66
+ const unsub = c.subscribe(() => count++)
67
+ c.set(1)
68
+ expect(count).toBe(1)
69
+ unsub()
70
+ c.set(2)
71
+ expect(count).toBe(1) // no more notifications
72
+ })
73
+
74
+ test("subscribe() returns working unsubscribe (multi listener)", () => {
75
+ const c = cell(0)
76
+ let a = 0
77
+ let b = 0
78
+ c.listen(() => a++)
79
+ const unsub = c.subscribe(() => b++)
80
+ c.set(1)
81
+ expect(a).toBe(1)
82
+ expect(b).toBe(1)
83
+ unsub()
84
+ c.set(2)
85
+ expect(a).toBe(2)
86
+ expect(b).toBe(1) // unsubscribed
87
+ })
88
+
89
+ test("cell() factory returns Cell instance", () => {
90
+ const c = cell("x")
91
+ expect(c).toBeInstanceOf(Cell)
92
+ })
93
+
94
+ test("multiple rapid updates notify correctly", () => {
95
+ const c = cell(0)
96
+ const values: number[] = []
97
+ c.listen(() => values.push(c.peek()))
98
+ c.set(1)
99
+ c.set(2)
100
+ c.set(3)
101
+ expect(values).toEqual([1, 2, 3])
102
+ })
103
+
104
+ test("NaN equality (Object.is)", () => {
105
+ const c = cell(Number.NaN)
106
+ let calls = 0
107
+ c.listen(() => calls++)
108
+ c.set(Number.NaN)
109
+ expect(calls).toBe(0) // Object.is(NaN, NaN) is true
110
+ })
111
+ })
@@ -0,0 +1,146 @@
1
+ import { computed } from "../computed"
2
+ import { effect } from "../effect"
3
+ import { signal } from "../signal"
4
+
5
+ describe("computed", () => {
6
+ test("computes derived value", () => {
7
+ const s = signal(2)
8
+ const doubled = computed(() => s() * 2)
9
+ expect(doubled()).toBe(4)
10
+ })
11
+
12
+ test("updates when dependency changes", () => {
13
+ const s = signal(3)
14
+ const tripled = computed(() => s() * 3)
15
+ expect(tripled()).toBe(9)
16
+ s.set(4)
17
+ expect(tripled()).toBe(12)
18
+ })
19
+
20
+ test("is lazy — does not compute until read", () => {
21
+ let computations = 0
22
+ const s = signal(0)
23
+ const c = computed(() => {
24
+ computations++
25
+ return s() + 1
26
+ })
27
+ expect(computations).toBe(0)
28
+ c() // first read
29
+ expect(computations).toBe(1)
30
+ })
31
+
32
+ test("is memoized — does not recompute on repeated reads", () => {
33
+ let computations = 0
34
+ const s = signal(5)
35
+ const c = computed(() => {
36
+ computations++
37
+ return s() * 2
38
+ })
39
+ c()
40
+ c()
41
+ c()
42
+ expect(computations).toBe(1)
43
+ })
44
+
45
+ test("recomputes only when dirty", () => {
46
+ let computations = 0
47
+ const s = signal(1)
48
+ const c = computed(() => {
49
+ computations++
50
+ return s()
51
+ })
52
+ c()
53
+ expect(computations).toBe(1)
54
+ s.set(2)
55
+ c()
56
+ expect(computations).toBe(2)
57
+ c()
58
+ expect(computations).toBe(2) // still memoized
59
+ })
60
+
61
+ test("chains correctly", () => {
62
+ const base = signal(2)
63
+ const doubled = computed(() => base() * 2)
64
+ const quadrupled = computed(() => doubled() * 2)
65
+ expect(quadrupled()).toBe(8)
66
+ base.set(3)
67
+ expect(quadrupled()).toBe(12)
68
+ })
69
+
70
+ test("dispose stops recomputation", () => {
71
+ const s = signal(1)
72
+ let computations = 0
73
+ const c = computed(() => {
74
+ computations++
75
+ return s() * 2
76
+ })
77
+ c() // initial
78
+ expect(computations).toBe(1)
79
+ c.dispose()
80
+ s.set(2)
81
+ // After dispose, reading returns stale value and does not recompute
82
+ // (the computed is no longer subscribed to s)
83
+ })
84
+
85
+ test("custom equals skips downstream notification when equal", () => {
86
+ const s = signal(3)
87
+ let downstream = 0
88
+
89
+ const c = computed(() => Math.floor(s() / 10), {
90
+ equals: (a, b) => a === b,
91
+ })
92
+
93
+ effect(() => {
94
+ c()
95
+ downstream++
96
+ })
97
+
98
+ expect(downstream).toBe(1)
99
+ expect(c()).toBe(0)
100
+
101
+ s.set(5) // Math.floor(5/10) = 0, same as before
102
+ expect(downstream).toBe(1) // no downstream update
103
+
104
+ s.set(15) // Math.floor(15/10) = 1, different
105
+ expect(downstream).toBe(2)
106
+ expect(c()).toBe(1)
107
+ })
108
+
109
+ test("custom equals with array comparison", () => {
110
+ const items = signal([1, 2, 3])
111
+ let downstream = 0
112
+
113
+ const sorted = computed(() => items().slice().sort(), {
114
+ equals: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
115
+ })
116
+
117
+ effect(() => {
118
+ sorted()
119
+ downstream++
120
+ })
121
+
122
+ expect(downstream).toBe(1)
123
+
124
+ // Set to same content in different array — equals returns true, no notification
125
+ items.set([1, 2, 3])
126
+ expect(downstream).toBe(1)
127
+
128
+ // Actually different content
129
+ items.set([1, 2, 4])
130
+ expect(downstream).toBe(2)
131
+ })
132
+
133
+ test("computed used as dependency inside an effect (subscribe path)", () => {
134
+ const s = signal(10)
135
+ const c = computed(() => s() + 1)
136
+ let result = 0
137
+
138
+ effect(() => {
139
+ result = c()
140
+ })
141
+
142
+ expect(result).toBe(11)
143
+ s.set(20)
144
+ expect(result).toBe(21)
145
+ })
146
+ })
@@ -0,0 +1,119 @@
1
+ import { createSelector } from "../createSelector"
2
+ import { effect } from "../effect"
3
+ import { signal } from "../signal"
4
+
5
+ describe("createSelector", () => {
6
+ test("returns true for the currently selected value", () => {
7
+ const selected = signal(1)
8
+ const isSelected = createSelector(() => selected())
9
+
10
+ let result = false
11
+ effect(() => {
12
+ result = isSelected(1)
13
+ })
14
+ expect(result).toBe(true)
15
+ })
16
+
17
+ test("returns false for non-selected values", () => {
18
+ const selected = signal(1)
19
+ const isSelected = createSelector(() => selected())
20
+
21
+ let result = true
22
+ effect(() => {
23
+ result = isSelected(2)
24
+ })
25
+ expect(result).toBe(false)
26
+ })
27
+
28
+ test("only notifies affected subscribers when selection changes", () => {
29
+ const selected = signal(1)
30
+ const isSelected = createSelector(() => selected())
31
+
32
+ let runs1 = 0
33
+ let runs2 = 0
34
+ let runs3 = 0
35
+
36
+ effect(() => {
37
+ isSelected(1)
38
+ runs1++
39
+ })
40
+ effect(() => {
41
+ isSelected(2)
42
+ runs2++
43
+ })
44
+ effect(() => {
45
+ isSelected(3)
46
+ runs3++
47
+ })
48
+
49
+ expect(runs1).toBe(1)
50
+ expect(runs2).toBe(1)
51
+ expect(runs3).toBe(1)
52
+
53
+ // Change selection from 1 to 2: only buckets 1 (deselected) and 2 (newly selected) should fire
54
+ selected.set(2)
55
+ expect(runs1).toBe(2) // deselected
56
+ expect(runs2).toBe(2) // newly selected
57
+ expect(runs3).toBe(1) // unaffected
58
+ })
59
+
60
+ test("does not notify when source changes to the same value", () => {
61
+ const selected = signal(1)
62
+ const isSelected = createSelector(() => selected())
63
+
64
+ let runs = 0
65
+ effect(() => {
66
+ isSelected(1)
67
+ runs++
68
+ })
69
+
70
+ selected.set(1) // same value
71
+ expect(runs).toBe(1)
72
+ })
73
+
74
+ test("works when changing to a value with no subscribers", () => {
75
+ const selected = signal(1)
76
+ const isSelected = createSelector(() => selected())
77
+
78
+ let runs = 0
79
+ effect(() => {
80
+ isSelected(1)
81
+ runs++
82
+ })
83
+
84
+ // Change to value 99 which has no subscriber bucket
85
+ selected.set(99)
86
+ expect(runs).toBe(2) // old bucket (1) notified
87
+ })
88
+
89
+ test("reuses host objects for the same value", () => {
90
+ const selected = signal(1)
91
+ const isSelected = createSelector(() => selected())
92
+
93
+ let result1 = false
94
+ let result2 = false
95
+ effect(() => {
96
+ result1 = isSelected(1)
97
+ })
98
+ // Second call with same value should reuse the host
99
+ effect(() => {
100
+ result2 = isSelected(1)
101
+ })
102
+
103
+ expect(result1).toBe(true)
104
+ expect(result2).toBe(true)
105
+
106
+ selected.set(2)
107
+ expect(result1).toBe(false)
108
+ expect(result2).toBe(false)
109
+ })
110
+
111
+ test("tracks correctly outside an effect (no activeEffect)", () => {
112
+ const selected = signal(1)
113
+ const isSelected = createSelector(() => selected())
114
+
115
+ // Calling outside an effect should still return the correct boolean
116
+ expect(isSelected(1)).toBe(true)
117
+ expect(isSelected(2)).toBe(false)
118
+ })
119
+ })
@@ -0,0 +1,196 @@
1
+ import { _notifyTraceListeners, inspectSignal, isTracing, onSignalUpdate, why } from "../debug"
2
+ import { signal } from "../signal"
3
+
4
+ describe("debug", () => {
5
+ describe("onSignalUpdate / isTracing", () => {
6
+ test("isTracing is false by default", () => {
7
+ expect(isTracing()).toBe(false)
8
+ })
9
+
10
+ test("registering a listener enables tracing", () => {
11
+ const dispose = onSignalUpdate(() => {})
12
+ expect(isTracing()).toBe(true)
13
+ dispose()
14
+ expect(isTracing()).toBe(false)
15
+ })
16
+
17
+ test("listener receives signal update events", () => {
18
+ const events: { name: string | undefined; prev: unknown; next: unknown }[] = []
19
+ const dispose = onSignalUpdate((e) => {
20
+ events.push({ name: e.name, prev: e.prev, next: e.next })
21
+ })
22
+
23
+ const s = signal(1, { name: "count" })
24
+ s.set(2)
25
+
26
+ expect(events.length).toBe(1)
27
+ expect(events[0]).toEqual({ name: "count", prev: 1, next: 2 })
28
+
29
+ dispose()
30
+ })
31
+
32
+ test("dispose removes only the specific listener", () => {
33
+ let calls1 = 0
34
+ let calls2 = 0
35
+ const dispose1 = onSignalUpdate(() => calls1++)
36
+ const dispose2 = onSignalUpdate(() => calls2++)
37
+
38
+ const s = signal(0)
39
+ s.set(1)
40
+ expect(calls1).toBe(1)
41
+ expect(calls2).toBe(1)
42
+
43
+ dispose1()
44
+
45
+ s.set(2)
46
+ expect(calls1).toBe(1) // removed
47
+ expect(calls2).toBe(2) // still active
48
+
49
+ dispose2()
50
+ expect(isTracing()).toBe(false)
51
+ })
52
+
53
+ test("dispose is safe to call when listeners already null", () => {
54
+ const dispose = onSignalUpdate(() => {})
55
+ dispose()
56
+ expect(isTracing()).toBe(false)
57
+ dispose() // should not throw — _traceListeners is null
58
+ })
59
+
60
+ test("_notifyTraceListeners does nothing when no listeners", () => {
61
+ const s = signal(0)
62
+ // Should not throw
63
+ _notifyTraceListeners(s, 0, 1)
64
+ })
65
+
66
+ test("event includes stack and timestamp", () => {
67
+ let event: { stack: string; timestamp: number } | undefined
68
+ const dispose = onSignalUpdate((e) => {
69
+ event = { stack: e.stack, timestamp: e.timestamp }
70
+ })
71
+
72
+ const s = signal(0)
73
+ s.set(1)
74
+
75
+ expect(event).toBeDefined()
76
+ expect(typeof event?.stack).toBe("string")
77
+ expect(typeof event?.timestamp).toBe("number")
78
+
79
+ dispose()
80
+ })
81
+ })
82
+
83
+ describe("why", () => {
84
+ test("logs signal updates to console", async () => {
85
+ const logs: unknown[][] = []
86
+ const origLog = console.log
87
+ console.log = (...args: unknown[]) => logs.push(args)
88
+
89
+ const s = signal(1, { name: "test" })
90
+ why()
91
+ s.set(2)
92
+
93
+ // Wait for microtask (auto-dispose)
94
+ await new Promise((r) => queueMicrotask(() => r(undefined)))
95
+
96
+ expect(logs.length).toBeGreaterThan(0)
97
+ console.log = origLog
98
+ })
99
+
100
+ test("logs 'no updates' when nothing changes", async () => {
101
+ const logs: unknown[][] = []
102
+ const origLog = console.log
103
+ console.log = (...args: unknown[]) => logs.push(args)
104
+
105
+ why()
106
+ // No signal updates
107
+
108
+ await new Promise((r) => queueMicrotask(() => r(undefined)))
109
+
110
+ const noUpdateLog =
111
+ logs.find((args) =>
112
+ typeof args[0] === "string" ? args[0].includes("No signal") : false,
113
+ ) ||
114
+ logs.find((args) => (typeof args[1] === "string" ? args[1].includes("No signal") : false))
115
+ expect(noUpdateLog).toBeDefined()
116
+ console.log = origLog
117
+ })
118
+
119
+ test("calling why() twice is ignored (already active)", async () => {
120
+ const logs: unknown[][] = []
121
+ const origLog = console.log
122
+ console.log = (...args: unknown[]) => logs.push(args)
123
+
124
+ why()
125
+ why() // should be ignored
126
+ const s = signal(0, { name: "x" })
127
+ s.set(1)
128
+
129
+ await new Promise((r) => queueMicrotask(() => r(undefined)))
130
+ // Should not throw or double-log
131
+ console.log = origLog
132
+ })
133
+
134
+ test("logs anonymous signal name when no name is set", async () => {
135
+ const logs: unknown[][] = []
136
+ const origLog = console.log
137
+ console.log = (...args: unknown[]) => logs.push(args)
138
+
139
+ const s = signal(0) // no name
140
+ why()
141
+ s.set(1)
142
+
143
+ await new Promise((r) => queueMicrotask(() => r(undefined)))
144
+
145
+ const anonLog = logs.find((args) =>
146
+ args.some((a) => typeof a === "string" && a.includes("anonymous")),
147
+ )
148
+ expect(anonLog).toBeDefined()
149
+ console.log = origLog
150
+ })
151
+ })
152
+
153
+ describe("inspectSignal", () => {
154
+ test("prints signal info and returns debug info", () => {
155
+ const groupCalls: unknown[][] = []
156
+ const logCalls: unknown[][] = []
157
+ const origGroup = console.group
158
+ const origLog = console.log
159
+ const origEnd = console.groupEnd
160
+ console.group = (...args: unknown[]) => groupCalls.push(args)
161
+ console.log = (...args: unknown[]) => logCalls.push(args)
162
+ console.groupEnd = () => {}
163
+
164
+ const s = signal(42, { name: "count" })
165
+ const info = inspectSignal(s)
166
+
167
+ expect(info.name).toBe("count")
168
+ expect(info.value).toBe(42)
169
+ expect(info.subscriberCount).toBe(0)
170
+ expect(groupCalls.length).toBe(1)
171
+ expect(logCalls.length).toBe(2) // value + subscribers
172
+
173
+ console.group = origGroup
174
+ console.log = origLog
175
+ console.groupEnd = origEnd
176
+ })
177
+
178
+ test("handles anonymous signal", () => {
179
+ const origGroup = console.group
180
+ const origLog = console.log
181
+ const origEnd = console.groupEnd
182
+ console.group = () => {}
183
+ console.log = () => {}
184
+ console.groupEnd = () => {}
185
+
186
+ const s = signal(0)
187
+ const info = inspectSignal(s)
188
+
189
+ expect(info.name).toBeUndefined()
190
+
191
+ console.group = origGroup
192
+ console.log = origLog
193
+ console.groupEnd = origEnd
194
+ })
195
+ })
196
+ })