@pyreon/rx 0.11.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 +50 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +343 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +214 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +45 -0
- package/src/aggregation.ts +86 -0
- package/src/collections.ts +189 -0
- package/src/index.ts +114 -0
- package/src/operators.ts +75 -0
- package/src/pipe.ts +66 -0
- package/src/search.ts +34 -0
- package/src/tests/aggregation.test.ts +83 -0
- package/src/tests/collections.test.ts +186 -0
- package/src/tests/operators.test.ts +153 -0
- package/src/tests/pipe.test.ts +51 -0
- package/src/tests/timing.test.ts +55 -0
- package/src/timing.ts +70 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import {
|
|
4
|
+
chunk,
|
|
5
|
+
filter,
|
|
6
|
+
find,
|
|
7
|
+
flatten,
|
|
8
|
+
groupBy,
|
|
9
|
+
keyBy,
|
|
10
|
+
last,
|
|
11
|
+
map,
|
|
12
|
+
mapValues,
|
|
13
|
+
skip,
|
|
14
|
+
sortBy,
|
|
15
|
+
take,
|
|
16
|
+
uniqBy,
|
|
17
|
+
} from "../collections"
|
|
18
|
+
|
|
19
|
+
type User = { id: number; name: string; role: string; active: boolean }
|
|
20
|
+
const users: User[] = [
|
|
21
|
+
{ id: 1, name: "Alice", role: "admin", active: true },
|
|
22
|
+
{ id: 2, name: "Bob", role: "viewer", active: false },
|
|
23
|
+
{ id: 3, name: "Charlie", role: "admin", active: true },
|
|
24
|
+
{ id: 4, name: "Diana", role: "editor", active: true },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
describe("collections — plain values", () => {
|
|
28
|
+
it("filter", () => {
|
|
29
|
+
expect(filter(users, (u) => u.active)).toHaveLength(3)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("map", () => {
|
|
33
|
+
expect(map(users, (u) => u.name)).toEqual(["Alice", "Bob", "Charlie", "Diana"])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("sortBy string key", () => {
|
|
37
|
+
const sorted = sortBy(users, "name")
|
|
38
|
+
expect(sorted[0]!.name).toBe("Alice")
|
|
39
|
+
expect(sorted[3]!.name).toBe("Diana")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("sortBy function", () => {
|
|
43
|
+
const sorted = sortBy(users, (u) => u.id)
|
|
44
|
+
expect(sorted[0]!.id).toBe(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("groupBy", () => {
|
|
48
|
+
const groups = groupBy(users, "role")
|
|
49
|
+
expect(groups.admin).toHaveLength(2)
|
|
50
|
+
expect(groups.viewer).toHaveLength(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("keyBy", () => {
|
|
54
|
+
const indexed = keyBy(users, "id")
|
|
55
|
+
expect(indexed["1"]!.name).toBe("Alice")
|
|
56
|
+
expect(indexed["3"]!.name).toBe("Charlie")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("uniqBy", () => {
|
|
60
|
+
const result = uniqBy(users, "role")
|
|
61
|
+
expect(result).toHaveLength(3) // admin, viewer, editor
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("take", () => {
|
|
65
|
+
expect(take(users, 2)).toHaveLength(2)
|
|
66
|
+
expect(take(users, 2)[0]!.name).toBe("Alice")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("chunk", () => {
|
|
70
|
+
const chunks = chunk(users, 2)
|
|
71
|
+
expect(chunks).toHaveLength(2)
|
|
72
|
+
expect(chunks[0]).toHaveLength(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("flatten", () => {
|
|
76
|
+
const nested = [[1, 2], [3, 4], [5]]
|
|
77
|
+
expect(flatten(nested)).toEqual([1, 2, 3, 4, 5])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("find", () => {
|
|
81
|
+
expect(find(users, (u) => u.name === "Bob")?.id).toBe(2)
|
|
82
|
+
expect(find(users, (u) => u.name === "Nobody")).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("skip", () => {
|
|
86
|
+
const result = skip(users, 2)
|
|
87
|
+
expect(result).toHaveLength(2)
|
|
88
|
+
expect(result[0]!.name).toBe("Charlie")
|
|
89
|
+
expect(result[1]!.name).toBe("Diana")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("last", () => {
|
|
93
|
+
const result = last(users, 2)
|
|
94
|
+
expect(result).toHaveLength(2)
|
|
95
|
+
expect(result[0]!.name).toBe("Charlie")
|
|
96
|
+
expect(result[1]!.name).toBe("Diana")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("mapValues", () => {
|
|
100
|
+
const record: Record<string, number> = { a: 1, b: 2, c: 3 }
|
|
101
|
+
const result = mapValues(record, (v) => v * 10)
|
|
102
|
+
expect(result).toEqual({ a: 10, b: 20, c: 30 })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("mapValues with key argument", () => {
|
|
106
|
+
const record: Record<string, number> = { x: 1, y: 2 }
|
|
107
|
+
const result = mapValues(record, (v, k) => `${k}=${v}`)
|
|
108
|
+
expect(result).toEqual({ x: "x=1", y: "y=2" })
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("collections — signal values (reactive)", () => {
|
|
113
|
+
it("filter returns computed that tracks signal", () => {
|
|
114
|
+
const src = signal(users)
|
|
115
|
+
const active = filter(src, (u) => u.active)
|
|
116
|
+
expect(active()).toHaveLength(3)
|
|
117
|
+
|
|
118
|
+
// Mutate source
|
|
119
|
+
src.set([...users, { id: 5, name: "Eve", role: "admin", active: true }])
|
|
120
|
+
expect(active()).toHaveLength(4)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("map returns computed", () => {
|
|
124
|
+
const src = signal(users)
|
|
125
|
+
const names = map(src, (u) => u.name)
|
|
126
|
+
expect(names()).toEqual(["Alice", "Bob", "Charlie", "Diana"])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("sortBy returns computed", () => {
|
|
130
|
+
const src = signal([{ n: "B" }, { n: "A" }, { n: "C" }])
|
|
131
|
+
const sorted = sortBy(src, "n")
|
|
132
|
+
expect(sorted()[0]!.n).toBe("A")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("groupBy returns computed", () => {
|
|
136
|
+
const src = signal(users)
|
|
137
|
+
const groups = groupBy(src, "role")
|
|
138
|
+
expect(groups().admin).toHaveLength(2)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("take returns computed", () => {
|
|
142
|
+
const src = signal(users)
|
|
143
|
+
const first2 = take(src, 2)
|
|
144
|
+
expect(first2()).toHaveLength(2)
|
|
145
|
+
|
|
146
|
+
src.set([users[0]!])
|
|
147
|
+
expect(first2()).toHaveLength(1)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("does not mutate original array", () => {
|
|
151
|
+
const original = [{ n: "B" }, { n: "A" }]
|
|
152
|
+
const src = signal(original)
|
|
153
|
+
sortBy(src, "n")()
|
|
154
|
+
expect(original[0]!.n).toBe("B") // original untouched
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("skip returns computed", () => {
|
|
158
|
+
const src = signal(users)
|
|
159
|
+
const skipped = skip(src, 3)
|
|
160
|
+
expect(skipped()).toHaveLength(1)
|
|
161
|
+
expect(skipped()[0]!.name).toBe("Diana")
|
|
162
|
+
|
|
163
|
+
src.set([...users, { id: 5, name: "Eve", role: "admin", active: true }])
|
|
164
|
+
expect(skipped()).toHaveLength(2)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("last returns computed", () => {
|
|
168
|
+
const src = signal(users)
|
|
169
|
+
const lastTwo = last(src, 2)
|
|
170
|
+
expect(lastTwo()).toHaveLength(2)
|
|
171
|
+
expect(lastTwo()[0]!.name).toBe("Charlie")
|
|
172
|
+
|
|
173
|
+
src.set([users[0]!])
|
|
174
|
+
expect(lastTwo()).toHaveLength(1)
|
|
175
|
+
expect(lastTwo()[0]!.name).toBe("Alice")
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("mapValues returns computed", () => {
|
|
179
|
+
const src = signal<Record<string, number>>({ a: 1, b: 2 })
|
|
180
|
+
const doubled = mapValues(src, (v) => v * 2)
|
|
181
|
+
expect(doubled()).toEqual({ a: 2, b: 4 })
|
|
182
|
+
|
|
183
|
+
src.set({ x: 10 })
|
|
184
|
+
expect(doubled()).toEqual({ x: 20 })
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { combine, distinct, scan } from "../operators"
|
|
4
|
+
|
|
5
|
+
describe("distinct", () => {
|
|
6
|
+
it("skips consecutive duplicate values", () => {
|
|
7
|
+
const src = signal(1)
|
|
8
|
+
const d = distinct(src)
|
|
9
|
+
expect(d()).toBe(1)
|
|
10
|
+
|
|
11
|
+
// Same value — should not re-emit
|
|
12
|
+
src.set(1)
|
|
13
|
+
expect(d()).toBe(1)
|
|
14
|
+
|
|
15
|
+
// Different value — should emit
|
|
16
|
+
src.set(2)
|
|
17
|
+
expect(d()).toBe(2)
|
|
18
|
+
|
|
19
|
+
// Back to same — should emit (not same as previous)
|
|
20
|
+
src.set(2)
|
|
21
|
+
expect(d()).toBe(2)
|
|
22
|
+
|
|
23
|
+
// New value
|
|
24
|
+
src.set(3)
|
|
25
|
+
expect(d()).toBe(3)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("supports custom equality function", () => {
|
|
29
|
+
const src = signal({ id: 1, name: "Alice" })
|
|
30
|
+
const d = distinct(src, (a, b) => a.id === b.id)
|
|
31
|
+
expect(d().name).toBe("Alice")
|
|
32
|
+
|
|
33
|
+
// Same id, different name — should be considered equal, skip
|
|
34
|
+
src.set({ id: 1, name: "Updated Alice" })
|
|
35
|
+
expect(d().name).toBe("Alice")
|
|
36
|
+
|
|
37
|
+
// Different id — should emit
|
|
38
|
+
src.set({ id: 2, name: "Bob" })
|
|
39
|
+
expect(d().name).toBe("Bob")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("works reactively with signal source", () => {
|
|
43
|
+
const src = signal("a")
|
|
44
|
+
const d = distinct(src)
|
|
45
|
+
expect(d()).toBe("a")
|
|
46
|
+
|
|
47
|
+
src.set("b")
|
|
48
|
+
expect(d()).toBe("b")
|
|
49
|
+
|
|
50
|
+
src.set("b")
|
|
51
|
+
expect(d()).toBe("b")
|
|
52
|
+
|
|
53
|
+
src.set("c")
|
|
54
|
+
expect(d()).toBe("c")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("scan", () => {
|
|
59
|
+
it("accumulates values over signal changes", () => {
|
|
60
|
+
const src = signal(0)
|
|
61
|
+
const total = scan(src, (acc, val) => acc + val, 0)
|
|
62
|
+
|
|
63
|
+
// Initial: reducer runs with initial source value
|
|
64
|
+
expect(total()).toBe(0)
|
|
65
|
+
|
|
66
|
+
src.set(1)
|
|
67
|
+
expect(total()).toBe(1)
|
|
68
|
+
|
|
69
|
+
src.set(3)
|
|
70
|
+
expect(total()).toBe(4)
|
|
71
|
+
|
|
72
|
+
src.set(2)
|
|
73
|
+
expect(total()).toBe(6)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("works with non-numeric accumulation", () => {
|
|
77
|
+
const src = signal("start")
|
|
78
|
+
const log = scan(src, (acc, val) => [...acc, val], [] as string[])
|
|
79
|
+
|
|
80
|
+
// Effect runs immediately with initial value
|
|
81
|
+
expect(log()).toEqual(["start"])
|
|
82
|
+
|
|
83
|
+
src.set("hello")
|
|
84
|
+
expect(log()).toEqual(["start", "hello"])
|
|
85
|
+
|
|
86
|
+
src.set("world")
|
|
87
|
+
expect(log()).toEqual(["start", "hello", "world"])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("is signal-reactive", () => {
|
|
91
|
+
const src = signal(10)
|
|
92
|
+
const running = scan(src, (acc, val) => acc + val, 0)
|
|
93
|
+
expect(running()).toBe(10)
|
|
94
|
+
|
|
95
|
+
src.set(5)
|
|
96
|
+
expect(running()).toBe(15)
|
|
97
|
+
|
|
98
|
+
// Different value triggers accumulation again
|
|
99
|
+
src.set(7)
|
|
100
|
+
expect(running()).toBe(22)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe("combine", () => {
|
|
105
|
+
it("combines 2 signals", () => {
|
|
106
|
+
const firstName = signal("John")
|
|
107
|
+
const lastName = signal("Doe")
|
|
108
|
+
const fullName = combine(firstName, lastName, (f, l) => `${f} ${l}`)
|
|
109
|
+
|
|
110
|
+
expect(fullName()).toBe("John Doe")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("combines 3 signals", () => {
|
|
114
|
+
const a = signal(1)
|
|
115
|
+
const b = signal(2)
|
|
116
|
+
const c = signal(3)
|
|
117
|
+
const total = combine(a, b, c, (x, y, z) => x + y + z)
|
|
118
|
+
|
|
119
|
+
expect(total()).toBe(6)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("reacts to updates from any source", () => {
|
|
123
|
+
const firstName = signal("John")
|
|
124
|
+
const lastName = signal("Doe")
|
|
125
|
+
const fullName = combine(firstName, lastName, (f, l) => `${f} ${l}`)
|
|
126
|
+
|
|
127
|
+
expect(fullName()).toBe("John Doe")
|
|
128
|
+
|
|
129
|
+
firstName.set("Jane")
|
|
130
|
+
expect(fullName()).toBe("Jane Doe")
|
|
131
|
+
|
|
132
|
+
lastName.set("Smith")
|
|
133
|
+
expect(fullName()).toBe("Jane Smith")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("reacts to updates from all 3 sources", () => {
|
|
137
|
+
const a = signal(1)
|
|
138
|
+
const b = signal(10)
|
|
139
|
+
const c = signal(100)
|
|
140
|
+
const sum = combine(a, b, c, (x, y, z) => x + y + z)
|
|
141
|
+
|
|
142
|
+
expect(sum()).toBe(111)
|
|
143
|
+
|
|
144
|
+
a.set(2)
|
|
145
|
+
expect(sum()).toBe(112)
|
|
146
|
+
|
|
147
|
+
b.set(20)
|
|
148
|
+
expect(sum()).toBe(122)
|
|
149
|
+
|
|
150
|
+
c.set(200)
|
|
151
|
+
expect(sum()).toBe(222)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { pipe } from "../pipe"
|
|
4
|
+
|
|
5
|
+
describe("pipe — plain values", () => {
|
|
6
|
+
it("chains transforms", () => {
|
|
7
|
+
const result = pipe(
|
|
8
|
+
[3, 1, 4, 1, 5, 9],
|
|
9
|
+
(arr) => arr.filter((n) => n > 2),
|
|
10
|
+
(arr) => arr.sort((a, b) => a - b),
|
|
11
|
+
(arr) => arr.slice(0, 3),
|
|
12
|
+
)
|
|
13
|
+
expect(result).toEqual([3, 4, 5])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("single transform", () => {
|
|
17
|
+
expect(pipe([1, 2, 3], (arr) => arr.length)).toBe(3)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe("pipe — signal values", () => {
|
|
22
|
+
it("returns computed that tracks source", () => {
|
|
23
|
+
const src = signal([3, 1, 4, 1, 5, 9])
|
|
24
|
+
const result = pipe(
|
|
25
|
+
src,
|
|
26
|
+
(arr) => arr.filter((n) => n > 2),
|
|
27
|
+
(arr) => arr.sort((a, b) => a - b),
|
|
28
|
+
)
|
|
29
|
+
expect(result()).toEqual([3, 4, 5, 9])
|
|
30
|
+
|
|
31
|
+
src.set([10, 1])
|
|
32
|
+
expect(result()).toEqual([10])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("supports type narrowing across steps", () => {
|
|
36
|
+
type User = { name: string; score: number }
|
|
37
|
+
const users = signal<User[]>([
|
|
38
|
+
{ name: "A", score: 5 },
|
|
39
|
+
{ name: "B", score: 10 },
|
|
40
|
+
{ name: "C", score: 3 },
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
const topNames = pipe(
|
|
44
|
+
users,
|
|
45
|
+
(items) => items.sort((a, b) => b.score - a.score),
|
|
46
|
+
(items) => items.slice(0, 2),
|
|
47
|
+
(items) => items.map((u) => u.name),
|
|
48
|
+
)
|
|
49
|
+
expect(topNames()).toEqual(["B", "A"])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { debounce, throttle } from "../timing"
|
|
4
|
+
|
|
5
|
+
describe("debounce", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("dispose() stops tracking source changes", () => {
|
|
11
|
+
vi.useFakeTimers()
|
|
12
|
+
const src = signal(0)
|
|
13
|
+
const debounced = debounce(src, 100)
|
|
14
|
+
expect(debounced()).toBe(0)
|
|
15
|
+
|
|
16
|
+
// Change source and advance past debounce window
|
|
17
|
+
src.set(1)
|
|
18
|
+
vi.advanceTimersByTime(100)
|
|
19
|
+
expect(debounced()).toBe(1)
|
|
20
|
+
|
|
21
|
+
// Dispose — further changes should not propagate
|
|
22
|
+
debounced.dispose()
|
|
23
|
+
|
|
24
|
+
src.set(2)
|
|
25
|
+
vi.advanceTimersByTime(200)
|
|
26
|
+
expect(debounced()).toBe(1) // still 1, not 2
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("throttle", () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.useRealTimers()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("dispose() stops tracking source changes", () => {
|
|
36
|
+
vi.useFakeTimers()
|
|
37
|
+
const src = signal(0)
|
|
38
|
+
const throttled = throttle(src, 100)
|
|
39
|
+
expect(throttled()).toBe(0)
|
|
40
|
+
|
|
41
|
+
// Advance time so the next change passes the throttle window
|
|
42
|
+
vi.advanceTimersByTime(100)
|
|
43
|
+
|
|
44
|
+
src.set(1)
|
|
45
|
+
expect(throttled()).toBe(1)
|
|
46
|
+
|
|
47
|
+
// Dispose — further changes should not propagate
|
|
48
|
+
throttled.dispose()
|
|
49
|
+
|
|
50
|
+
vi.advanceTimersByTime(200)
|
|
51
|
+
src.set(2)
|
|
52
|
+
vi.advanceTimersByTime(200)
|
|
53
|
+
expect(throttled()).toBe(1) // still 1, not 2
|
|
54
|
+
})
|
|
55
|
+
})
|
package/src/timing.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { effect, signal } from "@pyreon/reactivity"
|
|
2
|
+
import type { ReadableSignal } from "./types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Debounce a signal — emits the latest value after `ms` of silence.
|
|
6
|
+
* Returns a new signal that updates only after the source stops changing.
|
|
7
|
+
*
|
|
8
|
+
* Works both inside and outside component context.
|
|
9
|
+
* The returned signal has a `.dispose()` method to stop tracking.
|
|
10
|
+
*/
|
|
11
|
+
export function debounce<T>(
|
|
12
|
+
source: ReadableSignal<T>,
|
|
13
|
+
ms: number,
|
|
14
|
+
): ReadableSignal<T> & { dispose: () => void } {
|
|
15
|
+
const debounced = signal(source())
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
17
|
+
|
|
18
|
+
const fx = effect(() => {
|
|
19
|
+
const val = source()
|
|
20
|
+
if (timer) clearTimeout(timer)
|
|
21
|
+
timer = setTimeout(() => debounced.set(val), ms)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const dispose = () => {
|
|
25
|
+
if (timer) clearTimeout(timer)
|
|
26
|
+
fx.dispose()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return Object.assign(debounced as ReadableSignal<T>, { dispose })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Throttle a signal — emits at most once every `ms` milliseconds.
|
|
34
|
+
* Immediately emits on first change, then waits for the interval.
|
|
35
|
+
*
|
|
36
|
+
* Works both inside and outside component context.
|
|
37
|
+
* The returned signal has a `.dispose()` method to stop tracking.
|
|
38
|
+
*/
|
|
39
|
+
export function throttle<T>(
|
|
40
|
+
source: ReadableSignal<T>,
|
|
41
|
+
ms: number,
|
|
42
|
+
): ReadableSignal<T> & { dispose: () => void } {
|
|
43
|
+
const throttled = signal(source())
|
|
44
|
+
let lastEmit = 0
|
|
45
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
46
|
+
|
|
47
|
+
const fx = effect(() => {
|
|
48
|
+
const val = source()
|
|
49
|
+
const now = Date.now()
|
|
50
|
+
const elapsed = now - lastEmit
|
|
51
|
+
|
|
52
|
+
if (elapsed >= ms) {
|
|
53
|
+
throttled.set(val)
|
|
54
|
+
lastEmit = now
|
|
55
|
+
} else {
|
|
56
|
+
if (timer) clearTimeout(timer)
|
|
57
|
+
timer = setTimeout(() => {
|
|
58
|
+
throttled.set(val)
|
|
59
|
+
lastEmit = Date.now()
|
|
60
|
+
}, ms - elapsed)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const dispose = () => {
|
|
65
|
+
if (timer) clearTimeout(timer)
|
|
66
|
+
fx.dispose()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return Object.assign(throttled as ReadableSignal<T>, { dispose })
|
|
70
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { computed, signal } from "@pyreon/reactivity"
|
|
2
|
+
|
|
3
|
+
/** A readable signal — any callable that returns a value and tracks subscribers. */
|
|
4
|
+
export type ReadableSignal<T> = (() => T) & { peek?: () => T }
|
|
5
|
+
|
|
6
|
+
/** Result of a signal-aware transform — Computed when input is signal, plain when not. */
|
|
7
|
+
export type ReactiveResult<TInput, TOutput> =
|
|
8
|
+
TInput extends ReadableSignal<any> ? ReturnType<typeof computed<TOutput>> : TOutput
|
|
9
|
+
|
|
10
|
+
/** Key extractor — string key name or function. */
|
|
11
|
+
export type KeyOf<T> = keyof T | ((item: T) => string | number)
|
|
12
|
+
|
|
13
|
+
/** Resolve a key extractor to a function. */
|
|
14
|
+
export function resolveKey<T>(key: KeyOf<T>): (item: T) => string | number {
|
|
15
|
+
return typeof key === "function" ? key : (item: T) => String(item[key])
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Check if a value is a signal (callable function with .set or .peek). */
|
|
19
|
+
export function isSignal<T>(value: unknown): value is ReadableSignal<T> {
|
|
20
|
+
return typeof value === "function"
|
|
21
|
+
}
|