@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +838 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +725 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +342 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +40 -0
- package/src/batch.ts +44 -0
- package/src/cell.ts +71 -0
- package/src/computed.ts +71 -0
- package/src/createSelector.ts +56 -0
- package/src/debug.ts +134 -0
- package/src/effect.ts +152 -0
- package/src/index.ts +15 -0
- package/src/reconcile.ts +98 -0
- package/src/resource.ts +66 -0
- package/src/scope.ts +80 -0
- package/src/signal.ts +125 -0
- package/src/store.ts +139 -0
- package/src/tests/batch.test.ts +69 -0
- package/src/tests/bind.test.ts +84 -0
- package/src/tests/branches.test.ts +343 -0
- package/src/tests/cell.test.ts +111 -0
- package/src/tests/computed.test.ts +146 -0
- package/src/tests/createSelector.test.ts +119 -0
- package/src/tests/debug.test.ts +196 -0
- package/src/tests/effect.test.ts +256 -0
- package/src/tests/resource.test.ts +133 -0
- package/src/tests/scope.test.ts +202 -0
- package/src/tests/signal.test.ts +120 -0
- package/src/tests/store.test.ts +136 -0
- package/src/tests/tracking.test.ts +158 -0
- package/src/tests/watch.test.ts +146 -0
- package/src/tracking.ts +103 -0
- package/src/watch.ts +69 -0
|
@@ -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
|
+
})
|