@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,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
|
+
})
|
package/src/tracking.ts
ADDED
|
@@ -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
|
+
}
|