@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,256 @@
1
+ import { effect, renderEffect, setErrorHandler } from "../effect"
2
+ import { effectScope, setCurrentScope } from "../scope"
3
+ import { signal } from "../signal"
4
+
5
+ describe("effect", () => {
6
+ test("runs immediately", () => {
7
+ let ran = false
8
+ effect(() => {
9
+ ran = true
10
+ })
11
+ expect(ran).toBe(true)
12
+ })
13
+
14
+ test("re-runs when tracked signal changes", () => {
15
+ const s = signal(0)
16
+ let count = 0
17
+ effect(() => {
18
+ s() // track
19
+ count++
20
+ })
21
+ expect(count).toBe(1)
22
+ s.set(1)
23
+ expect(count).toBe(2)
24
+ s.set(2)
25
+ expect(count).toBe(3)
26
+ })
27
+
28
+ test("does not re-run after dispose", () => {
29
+ const s = signal(0)
30
+ let count = 0
31
+ const e = effect(() => {
32
+ s()
33
+ count++
34
+ })
35
+ e.dispose()
36
+ s.set(1)
37
+ expect(count).toBe(1) // only the initial run
38
+ })
39
+
40
+ test("tracks multiple signals", () => {
41
+ const a = signal(1)
42
+ const b = signal(2)
43
+ let result = 0
44
+ effect(() => {
45
+ result = a() + b()
46
+ })
47
+ expect(result).toBe(3)
48
+ a.set(10)
49
+ expect(result).toBe(12)
50
+ b.set(20)
51
+ expect(result).toBe(30)
52
+ })
53
+
54
+ test("does not track signals accessed after conditional branch", () => {
55
+ const toggle = signal(true)
56
+ const a = signal(1)
57
+ const b = signal(100)
58
+ let result = 0
59
+ effect(() => {
60
+ result = toggle() ? a() : b()
61
+ })
62
+ expect(result).toBe(1)
63
+ a.set(2)
64
+ expect(result).toBe(2)
65
+ toggle.set(false)
66
+ expect(result).toBe(100)
67
+ // a is no longer tracked
68
+ a.set(999)
69
+ expect(result).toBe(100)
70
+ })
71
+
72
+ test("catches errors via default error handler", () => {
73
+ const errors: unknown[] = []
74
+ const origError = console.error
75
+ console.error = (...args: unknown[]) => errors.push(args)
76
+
77
+ const s = signal(0)
78
+ effect(() => {
79
+ s()
80
+ throw new Error("boom")
81
+ })
82
+
83
+ expect(errors.length).toBe(1)
84
+ console.error = origError
85
+ })
86
+
87
+ test("calls cleanup before re-run", () => {
88
+ const s = signal(0)
89
+ let cleanups = 0
90
+ effect(() => {
91
+ s()
92
+ return () => {
93
+ cleanups++
94
+ }
95
+ })
96
+ expect(cleanups).toBe(0)
97
+ s.set(1) // re-run: previous cleanup fires
98
+ expect(cleanups).toBe(1)
99
+ s.set(2)
100
+ expect(cleanups).toBe(2)
101
+ })
102
+
103
+ test("calls cleanup on dispose", () => {
104
+ const s = signal(0)
105
+ let cleanups = 0
106
+ const e = effect(() => {
107
+ s()
108
+ return () => {
109
+ cleanups++
110
+ }
111
+ })
112
+ expect(cleanups).toBe(0)
113
+ e.dispose()
114
+ expect(cleanups).toBe(1)
115
+ // Disposing again should not call cleanup again
116
+ e.dispose()
117
+ expect(cleanups).toBe(1)
118
+ })
119
+
120
+ test("cleanup errors are caught by error handler", () => {
121
+ const caught: unknown[] = []
122
+ setErrorHandler((err) => caught.push(err))
123
+
124
+ const s = signal(0)
125
+ effect(() => {
126
+ s()
127
+ return () => {
128
+ throw new Error("cleanup boom")
129
+ }
130
+ })
131
+ s.set(1) // triggers cleanup which throws
132
+ expect(caught.length).toBe(1)
133
+ expect((caught[0] as Error).message).toBe("cleanup boom")
134
+
135
+ // Restore default handler
136
+ setErrorHandler((_err) => {})
137
+ })
138
+
139
+ test("works with no cleanup return (backwards compatible)", () => {
140
+ const s = signal(0)
141
+ let count = 0
142
+ effect(() => {
143
+ s()
144
+ count++
145
+ // no return
146
+ })
147
+ expect(count).toBe(1)
148
+ s.set(1)
149
+ expect(count).toBe(2)
150
+ })
151
+
152
+ test("setErrorHandler replaces the error handler", () => {
153
+ const caught: unknown[] = []
154
+ setErrorHandler((err) => caught.push(err))
155
+
156
+ const s = signal(0)
157
+ effect(() => {
158
+ s()
159
+ throw new Error("custom")
160
+ })
161
+
162
+ expect(caught.length).toBe(1)
163
+ expect((caught[0] as Error).message).toBe("custom")
164
+
165
+ // Restore default handler
166
+ setErrorHandler((_err) => {})
167
+ })
168
+
169
+ test("effect notifies scope on re-run (not first run)", async () => {
170
+ const scope = effectScope()
171
+ setCurrentScope(scope)
172
+
173
+ let updateCount = 0
174
+ scope.addUpdateHook(() => {
175
+ updateCount++
176
+ })
177
+
178
+ const s = signal(0)
179
+ effect(() => {
180
+ s()
181
+ })
182
+
183
+ setCurrentScope(null)
184
+
185
+ expect(updateCount).toBe(0) // first run does not notify
186
+
187
+ s.set(1) // re-run triggers notifyEffectRan
188
+ await new Promise((r) => setTimeout(r, 10))
189
+ expect(updateCount).toBe(1)
190
+
191
+ scope.stop()
192
+ })
193
+ })
194
+
195
+ describe("renderEffect", () => {
196
+ test("runs immediately and tracks signals", () => {
197
+ const s = signal(0)
198
+ let count = 0
199
+ renderEffect(() => {
200
+ s()
201
+ count++
202
+ })
203
+ expect(count).toBe(1)
204
+ s.set(1)
205
+ expect(count).toBe(2)
206
+ })
207
+
208
+ test("dispose stops tracking", () => {
209
+ const s = signal(0)
210
+ let count = 0
211
+ const dispose = renderEffect(() => {
212
+ s()
213
+ count++
214
+ })
215
+ expect(count).toBe(1)
216
+ dispose()
217
+ s.set(1)
218
+ expect(count).toBe(1)
219
+ })
220
+
221
+ test("dispose is idempotent", () => {
222
+ const s = signal(0)
223
+ const dispose = renderEffect(() => {
224
+ s()
225
+ })
226
+ dispose()
227
+ dispose() // should not throw
228
+ })
229
+
230
+ test("tracks dynamic dependencies", () => {
231
+ const toggle = signal(true)
232
+ const a = signal(1)
233
+ const b = signal(100)
234
+ let result = 0
235
+ renderEffect(() => {
236
+ result = toggle() ? a() : b()
237
+ })
238
+ expect(result).toBe(1)
239
+ toggle.set(false)
240
+ expect(result).toBe(100)
241
+ a.set(999) // no longer tracked
242
+ expect(result).toBe(100)
243
+ })
244
+
245
+ test("does not re-run after disposed during signal update", () => {
246
+ const s = signal(0)
247
+ let count = 0
248
+ const dispose = renderEffect(() => {
249
+ s()
250
+ count++
251
+ })
252
+ dispose()
253
+ s.set(5)
254
+ expect(count).toBe(1)
255
+ })
256
+ })
@@ -0,0 +1,133 @@
1
+ import { createResource } from "../resource"
2
+ import { signal } from "../signal"
3
+
4
+ describe("createResource", () => {
5
+ test("fetches data when source changes", async () => {
6
+ const userId = signal(1)
7
+ const resource = createResource(
8
+ () => userId(),
9
+ (id) => Promise.resolve(`user-${id}`),
10
+ )
11
+
12
+ expect(resource.loading()).toBe(true)
13
+ expect(resource.data()).toBeUndefined()
14
+ expect(resource.error()).toBeUndefined()
15
+
16
+ await new Promise((r) => setTimeout(r, 10))
17
+
18
+ expect(resource.data()).toBe("user-1")
19
+ expect(resource.loading()).toBe(false)
20
+ expect(resource.error()).toBeUndefined()
21
+ })
22
+
23
+ test("re-fetches when source signal changes", async () => {
24
+ const userId = signal(1)
25
+ const resource = createResource(
26
+ () => userId(),
27
+ (id) => Promise.resolve(`user-${id}`),
28
+ )
29
+
30
+ await new Promise((r) => setTimeout(r, 10))
31
+ expect(resource.data()).toBe("user-1")
32
+
33
+ userId.set(2)
34
+ expect(resource.loading()).toBe(true)
35
+
36
+ await new Promise((r) => setTimeout(r, 10))
37
+ expect(resource.data()).toBe("user-2")
38
+ expect(resource.loading()).toBe(false)
39
+ })
40
+
41
+ test("handles fetcher errors", async () => {
42
+ const userId = signal(1)
43
+ const resource = createResource(
44
+ () => userId(),
45
+ (_id) => Promise.reject(new Error("network error")),
46
+ )
47
+
48
+ await new Promise((r) => setTimeout(r, 10))
49
+
50
+ expect(resource.error()).toBeInstanceOf(Error)
51
+ expect((resource.error() as Error).message).toBe("network error")
52
+ expect(resource.loading()).toBe(false)
53
+ expect(resource.data()).toBeUndefined()
54
+ })
55
+
56
+ test("refetch re-runs the fetcher with current source", async () => {
57
+ let fetchCount = 0
58
+ const userId = signal(1)
59
+ const resource = createResource(
60
+ () => userId(),
61
+ (id) => {
62
+ fetchCount++
63
+ return Promise.resolve(`user-${id}-${fetchCount}`)
64
+ },
65
+ )
66
+
67
+ await new Promise((r) => setTimeout(r, 10))
68
+ expect(resource.data()).toBe("user-1-1")
69
+
70
+ resource.refetch()
71
+ await new Promise((r) => setTimeout(r, 10))
72
+ expect(resource.data()).toBe("user-1-2")
73
+ })
74
+
75
+ test("ignores stale responses (race condition)", async () => {
76
+ const userId = signal(1)
77
+ const resolvers: ((v: string) => void)[] = []
78
+
79
+ const resource = createResource(
80
+ () => userId(),
81
+ (_id) =>
82
+ new Promise<string>((resolve) => {
83
+ resolvers.push((v) => resolve(v))
84
+ }),
85
+ )
86
+
87
+ // First fetch is in flight
88
+ expect(resolvers.length).toBe(1)
89
+
90
+ // Change source — triggers second fetch
91
+ userId.set(2)
92
+ expect(resolvers.length).toBe(2)
93
+
94
+ // Resolve the SECOND request first
95
+ resolvers[1]?.("user-2")
96
+ await new Promise((r) => setTimeout(r, 10))
97
+ expect(resource.data()).toBe("user-2")
98
+
99
+ // Now resolve the FIRST (stale) request — should be ignored
100
+ resolvers[0]?.("user-1")
101
+ await new Promise((r) => setTimeout(r, 10))
102
+ expect(resource.data()).toBe("user-2") // still user-2, not user-1
103
+ })
104
+
105
+ test("ignores stale errors (race condition)", async () => {
106
+ const userId = signal(1)
107
+ const rejecters: ((e: Error) => void)[] = []
108
+ const resolvers: ((v: string) => void)[] = []
109
+
110
+ const resource = createResource(
111
+ () => userId(),
112
+ (_id) =>
113
+ new Promise<string>((resolve, reject) => {
114
+ resolvers.push(resolve)
115
+ rejecters.push(reject)
116
+ }),
117
+ )
118
+
119
+ // First fetch in flight, change source
120
+ userId.set(2)
121
+
122
+ // Resolve second request
123
+ resolvers[1]?.("user-2")
124
+ await new Promise((r) => setTimeout(r, 10))
125
+ expect(resource.data()).toBe("user-2")
126
+
127
+ // Reject first (stale) request — should be ignored
128
+ rejecters[0]?.(new Error("stale error"))
129
+ await new Promise((r) => setTimeout(r, 10))
130
+ expect(resource.error()).toBeUndefined()
131
+ expect(resource.data()).toBe("user-2")
132
+ })
133
+ })
@@ -0,0 +1,202 @@
1
+ import { effect } from "../effect"
2
+ import { EffectScope, effectScope, getCurrentScope, setCurrentScope } from "../scope"
3
+ import { signal } from "../signal"
4
+
5
+ describe("effectScope", () => {
6
+ test("creates an EffectScope instance", () => {
7
+ const scope = effectScope()
8
+ expect(scope).toBeInstanceOf(EffectScope)
9
+ })
10
+
11
+ test("getCurrentScope returns null by default", () => {
12
+ expect(getCurrentScope()).toBeNull()
13
+ })
14
+
15
+ test("setCurrentScope sets and clears the current scope", () => {
16
+ const scope = effectScope()
17
+ setCurrentScope(scope)
18
+ expect(getCurrentScope()).toBe(scope)
19
+ setCurrentScope(null)
20
+ expect(getCurrentScope()).toBeNull()
21
+ })
22
+
23
+ test("effects created within a scope are disposed on stop", () => {
24
+ const scope = effectScope()
25
+ setCurrentScope(scope)
26
+
27
+ const s = signal(0)
28
+ let count = 0
29
+ effect(() => {
30
+ s()
31
+ count++
32
+ })
33
+
34
+ setCurrentScope(null)
35
+
36
+ expect(count).toBe(1)
37
+ s.set(1)
38
+ expect(count).toBe(2)
39
+
40
+ scope.stop()
41
+ s.set(2)
42
+ expect(count).toBe(2) // effect disposed, no re-run
43
+ })
44
+
45
+ test("stop is idempotent — second call does nothing", () => {
46
+ const scope = effectScope()
47
+ setCurrentScope(scope)
48
+
49
+ const s = signal(0)
50
+ let count = 0
51
+ effect(() => {
52
+ s()
53
+ count++
54
+ })
55
+
56
+ setCurrentScope(null)
57
+ scope.stop()
58
+ scope.stop() // should not throw
59
+ s.set(1)
60
+ expect(count).toBe(1)
61
+ })
62
+
63
+ test("add is ignored after scope is stopped", () => {
64
+ const scope = effectScope()
65
+ scope.stop()
66
+ // Should not throw — add is silently ignored
67
+ scope.add({ dispose() {} })
68
+ })
69
+
70
+ test("runInScope temporarily re-activates the scope", () => {
71
+ const scope = effectScope()
72
+ setCurrentScope(null)
73
+
74
+ const s = signal(0)
75
+ let count = 0
76
+
77
+ scope.runInScope(() => {
78
+ effect(() => {
79
+ s()
80
+ count++
81
+ })
82
+ })
83
+
84
+ expect(getCurrentScope()).toBeNull() // restored
85
+ expect(count).toBe(1)
86
+ s.set(1)
87
+ expect(count).toBe(2)
88
+
89
+ scope.stop()
90
+ s.set(2)
91
+ expect(count).toBe(2) // disposed via scope
92
+ })
93
+
94
+ test("runInScope restores previous scope even on error", () => {
95
+ const scope = effectScope()
96
+ const prevScope = effectScope()
97
+ setCurrentScope(prevScope)
98
+
99
+ try {
100
+ scope.runInScope(() => {
101
+ expect(getCurrentScope()).toBe(scope)
102
+ throw new Error("test")
103
+ })
104
+ } catch {
105
+ // expected
106
+ }
107
+
108
+ expect(getCurrentScope()).toBe(prevScope)
109
+ setCurrentScope(null)
110
+ })
111
+
112
+ test("runInScope returns the function's return value", () => {
113
+ const scope = effectScope()
114
+ const result = scope.runInScope(() => 42)
115
+ expect(result).toBe(42)
116
+ })
117
+
118
+ test("addUpdateHook + notifyEffectRan fires hooks via microtask", async () => {
119
+ const scope = effectScope()
120
+ let hookCalled = 0
121
+
122
+ scope.addUpdateHook(() => {
123
+ hookCalled++
124
+ })
125
+
126
+ scope.notifyEffectRan()
127
+ expect(hookCalled).toBe(0) // not yet — microtask
128
+
129
+ await new Promise((r) => setTimeout(r, 10))
130
+ expect(hookCalled).toBe(1)
131
+ })
132
+
133
+ test("notifyEffectRan does nothing when no update hooks", async () => {
134
+ const scope = effectScope()
135
+ // Should not throw — early return when _updateHooks is empty
136
+ scope.notifyEffectRan()
137
+ await new Promise((r) => setTimeout(r, 10))
138
+ })
139
+
140
+ test("notifyEffectRan does nothing after scope is stopped", async () => {
141
+ const scope = effectScope()
142
+ let hookCalled = 0
143
+
144
+ scope.addUpdateHook(() => {
145
+ hookCalled++
146
+ })
147
+
148
+ scope.stop()
149
+ scope.notifyEffectRan()
150
+
151
+ await new Promise((r) => setTimeout(r, 10))
152
+ expect(hookCalled).toBe(0)
153
+ })
154
+
155
+ test("notifyEffectRan deduplicates — only one microtask while pending", async () => {
156
+ const scope = effectScope()
157
+ let hookCalled = 0
158
+
159
+ scope.addUpdateHook(() => {
160
+ hookCalled++
161
+ })
162
+
163
+ scope.notifyEffectRan()
164
+ scope.notifyEffectRan()
165
+ scope.notifyEffectRan()
166
+
167
+ await new Promise((r) => setTimeout(r, 10))
168
+ expect(hookCalled).toBe(1) // only fired once
169
+ })
170
+
171
+ test("notifyEffectRan skips hooks if scope stopped before microtask fires", async () => {
172
+ const scope = effectScope()
173
+ let hookCalled = 0
174
+
175
+ scope.addUpdateHook(() => {
176
+ hookCalled++
177
+ })
178
+
179
+ scope.notifyEffectRan()
180
+ scope.stop() // stop before microtask runs
181
+
182
+ await new Promise((r) => setTimeout(r, 10))
183
+ expect(hookCalled).toBe(0)
184
+ })
185
+
186
+ test("onUpdate hook errors are caught and logged", async () => {
187
+ const scope = effectScope()
188
+ const errors: unknown[] = []
189
+ const origError = console.error
190
+ console.error = (...args: unknown[]) => errors.push(args)
191
+
192
+ scope.addUpdateHook(() => {
193
+ throw new Error("hook error")
194
+ })
195
+
196
+ scope.notifyEffectRan()
197
+ await new Promise((r) => setTimeout(r, 10))
198
+
199
+ expect(errors.length).toBe(1)
200
+ console.error = origError
201
+ })
202
+ })
@@ -0,0 +1,120 @@
1
+ import { effect } from "../effect"
2
+ import { signal } from "../signal"
3
+
4
+ describe("signal", () => {
5
+ test("reads initial value", () => {
6
+ const s = signal(42)
7
+ expect(s()).toBe(42)
8
+ })
9
+
10
+ test("set updates value", () => {
11
+ const s = signal(0)
12
+ s.set(10)
13
+ expect(s()).toBe(10)
14
+ })
15
+
16
+ test("update transforms value", () => {
17
+ const s = signal(5)
18
+ s.update((n) => n * 2)
19
+ expect(s()).toBe(10)
20
+ })
21
+
22
+ test("set with same value does not notify", () => {
23
+ const s = signal(1)
24
+ let calls = 0
25
+ effect(() => {
26
+ s() // track
27
+ calls++
28
+ })
29
+ expect(calls).toBe(1) // initial run
30
+ s.set(1) // same value — no notification
31
+ expect(calls).toBe(1)
32
+ s.set(2) // different value — notifies
33
+ expect(calls).toBe(2)
34
+ })
35
+
36
+ test("works with objects", () => {
37
+ const s = signal({ x: 1 })
38
+ s.update((o) => ({ ...o, x: 2 }))
39
+ expect(s().x).toBe(2)
40
+ })
41
+
42
+ test("works with null and undefined", () => {
43
+ const s = signal<string | null>(null)
44
+ expect(s()).toBeNull()
45
+ s.set("hello")
46
+ expect(s()).toBe("hello")
47
+ })
48
+
49
+ test("peek reads value without tracking", () => {
50
+ const s = signal(42)
51
+ let count = 0
52
+ effect(() => {
53
+ s.peek() // should NOT track
54
+ count++
55
+ })
56
+ expect(count).toBe(1)
57
+ s.set(100)
58
+ expect(count).toBe(1) // no re-run because peek doesn't track
59
+ expect(s.peek()).toBe(100)
60
+ })
61
+
62
+ test("subscribe adds a static listener", () => {
63
+ const s = signal(0)
64
+ let notified = 0
65
+ const unsub = s.subscribe(() => {
66
+ notified++
67
+ })
68
+
69
+ s.set(1)
70
+ expect(notified).toBe(1)
71
+ s.set(2)
72
+ expect(notified).toBe(2)
73
+
74
+ unsub()
75
+ s.set(3)
76
+ expect(notified).toBe(2) // unsubscribed
77
+ })
78
+
79
+ test("subscribe disposer is safe to call multiple times", () => {
80
+ const s = signal(0)
81
+ const unsub = s.subscribe(() => {})
82
+ unsub()
83
+ unsub() // should not throw
84
+ })
85
+
86
+ test("label getter returns name from options", () => {
87
+ const s = signal(0, { name: "counter" })
88
+ expect(s.label).toBe("counter")
89
+ })
90
+
91
+ test("label setter updates the name", () => {
92
+ const s = signal(0)
93
+ expect(s.label).toBeUndefined()
94
+ s.label = "renamed"
95
+ expect(s.label).toBe("renamed")
96
+ })
97
+
98
+ test("debug() returns signal info", () => {
99
+ const s = signal(42, { name: "test" })
100
+ const info = s.debug()
101
+ expect(info.name).toBe("test")
102
+ expect(info.value).toBe(42)
103
+ expect(info.subscriberCount).toBe(0)
104
+ })
105
+
106
+ test("debug() reports subscriber count", () => {
107
+ const s = signal(0)
108
+ s.subscribe(() => {})
109
+ s.subscribe(() => {})
110
+ const info = s.debug()
111
+ expect(info.subscriberCount).toBe(2)
112
+ })
113
+
114
+ test("signal without options has undefined name", () => {
115
+ const s = signal(0)
116
+ expect(s.label).toBeUndefined()
117
+ const info = s.debug()
118
+ expect(info.name).toBeUndefined()
119
+ })
120
+ })