@zeix/cause-effect 0.17.1 → 0.17.3
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/.ai-context.md +13 -0
- package/.github/copilot-instructions.md +4 -0
- package/.zed/settings.json +3 -0
- package/CLAUDE.md +41 -7
- package/README.md +48 -25
- package/archive/benchmark.ts +0 -5
- package/archive/collection.ts +6 -65
- package/archive/composite.ts +85 -0
- package/archive/computed.ts +18 -20
- package/archive/list.ts +7 -75
- package/archive/memo.ts +15 -15
- package/archive/state.ts +2 -1
- package/archive/store.ts +8 -78
- package/archive/task.ts +20 -25
- package/index.dev.js +508 -526
- package/index.js +1 -1
- package/index.ts +9 -11
- package/package.json +6 -6
- package/src/classes/collection.ts +70 -107
- package/src/classes/computed.ts +165 -149
- package/src/classes/list.ts +145 -107
- package/src/classes/ref.ts +19 -17
- package/src/classes/state.ts +21 -17
- package/src/classes/store.ts +125 -73
- package/src/diff.ts +2 -1
- package/src/effect.ts +17 -10
- package/src/errors.ts +14 -1
- package/src/resolve.ts +1 -1
- package/src/signal.ts +3 -2
- package/src/system.ts +159 -61
- package/src/util.ts +0 -6
- package/test/batch.test.ts +4 -11
- package/test/benchmark.test.ts +4 -2
- package/test/collection.test.ts +106 -107
- package/test/computed.test.ts +351 -112
- package/test/effect.test.ts +2 -2
- package/test/list.test.ts +62 -102
- package/test/ref.test.ts +128 -2
- package/test/state.test.ts +16 -22
- package/test/store.test.ts +101 -108
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +3 -3
- package/types/src/classes/collection.d.ts +9 -10
- package/types/src/classes/computed.d.ts +17 -20
- package/types/src/classes/list.d.ts +8 -6
- package/types/src/classes/ref.d.ts +8 -12
- package/types/src/classes/state.d.ts +5 -8
- package/types/src/classes/store.d.ts +14 -13
- package/types/src/effect.d.ts +1 -2
- package/types/src/errors.d.ts +2 -1
- package/types/src/signal.d.ts +3 -2
- package/types/src/system.d.ts +47 -34
- package/types/src/util.d.ts +1 -2
- package/src/classes/composite.ts +0 -176
- package/types/src/classes/composite.d.ts +0 -15
package/src/system.ts
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
|
+
import { assert, type Guard } from './errors'
|
|
2
|
+
import type { UnknownSignal } from './signal'
|
|
3
|
+
|
|
1
4
|
/* === Types === */
|
|
2
5
|
|
|
3
6
|
type Cleanup = () => void
|
|
4
7
|
|
|
8
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: optional Cleanup return type
|
|
9
|
+
type MaybeCleanup = Cleanup | undefined | void
|
|
10
|
+
|
|
5
11
|
type Watcher = {
|
|
6
12
|
(): void
|
|
13
|
+
run(): void
|
|
7
14
|
onCleanup(cleanup: Cleanup): void
|
|
8
15
|
stop(): void
|
|
9
16
|
}
|
|
10
17
|
|
|
11
|
-
type
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
sort: readonly string[]
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
type Listener<K extends keyof Notifications> = (
|
|
19
|
-
payload: Notifications[K],
|
|
20
|
-
) => void
|
|
21
|
-
|
|
22
|
-
type Listeners = {
|
|
23
|
-
[K in keyof Notifications]: Set<Listener<K>>
|
|
18
|
+
type SignalOptions<T extends unknown & {}> = {
|
|
19
|
+
guard?: Guard<T>
|
|
20
|
+
watched?: () => void
|
|
21
|
+
unwatched?: () => void
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
/* === Internal === */
|
|
@@ -28,94 +26,206 @@ type Listeners = {
|
|
|
28
26
|
// Currently active watcher
|
|
29
27
|
let activeWatcher: Watcher | undefined
|
|
30
28
|
|
|
29
|
+
const watchersMap = new WeakMap<UnknownSignal, Set<Watcher>>()
|
|
30
|
+
const watchedCallbackMap = new WeakMap<object, () => void>()
|
|
31
|
+
const unwatchedCallbackMap = new WeakMap<object, () => void>()
|
|
32
|
+
|
|
31
33
|
// Queue of pending watcher reactions for batched change notifications
|
|
32
34
|
const pendingReactions = new Set<() => void>()
|
|
33
35
|
let batchDepth = 0
|
|
34
36
|
|
|
37
|
+
/* === Constants === */
|
|
38
|
+
|
|
39
|
+
// biome-ignore lint/suspicious/noExplicitAny: Deliberately using any to be used as a placeholder value in any signal
|
|
40
|
+
const UNSET: any = Symbol()
|
|
41
|
+
|
|
35
42
|
/* === Functions === */
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
|
-
* Create a watcher
|
|
45
|
+
* Create a watcher to observe changes in signals.
|
|
39
46
|
*
|
|
40
|
-
* A watcher
|
|
47
|
+
* A watcher combines push and pull reaction functions with onCleanup and stop methods
|
|
41
48
|
*
|
|
42
|
-
* @since 0.
|
|
43
|
-
* @param {() => void}
|
|
49
|
+
* @since 0.17.3
|
|
50
|
+
* @param {() => void} push - Function to be called when the state changes (push)
|
|
51
|
+
* @param {() => void} pull - Function to be called on demand from consumers (pull)
|
|
44
52
|
* @returns {Watcher} - Watcher object with off and cleanup methods
|
|
45
53
|
*/
|
|
46
|
-
const createWatcher = (
|
|
54
|
+
const createWatcher = (push: () => void, pull: () => void): Watcher => {
|
|
47
55
|
const cleanups = new Set<Cleanup>()
|
|
48
|
-
const watcher =
|
|
56
|
+
const watcher = push as Partial<Watcher>
|
|
57
|
+
watcher.run = () => {
|
|
58
|
+
const prev = activeWatcher
|
|
59
|
+
activeWatcher = watcher as Watcher
|
|
60
|
+
try {
|
|
61
|
+
pull()
|
|
62
|
+
} finally {
|
|
63
|
+
activeWatcher = prev
|
|
64
|
+
}
|
|
65
|
+
}
|
|
49
66
|
watcher.onCleanup = (cleanup: Cleanup) => {
|
|
50
67
|
cleanups.add(cleanup)
|
|
51
68
|
}
|
|
52
69
|
watcher.stop = () => {
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
try {
|
|
71
|
+
for (const cleanup of cleanups) cleanup()
|
|
72
|
+
} finally {
|
|
73
|
+
cleanups.clear()
|
|
74
|
+
}
|
|
55
75
|
}
|
|
56
76
|
return watcher as Watcher
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
/**
|
|
60
|
-
*
|
|
80
|
+
* Run a function with signal reads in a non-tracking context.
|
|
61
81
|
*
|
|
62
|
-
* @param {
|
|
82
|
+
* @param {() => void} callback - Callback
|
|
63
83
|
*/
|
|
84
|
+
const untrack = (callback: () => void): void => {
|
|
85
|
+
const prev = activeWatcher
|
|
86
|
+
activeWatcher = undefined
|
|
87
|
+
try {
|
|
88
|
+
callback()
|
|
89
|
+
} finally {
|
|
90
|
+
activeWatcher = prev
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const registerWatchCallbacks = (
|
|
95
|
+
signal: UnknownSignal,
|
|
96
|
+
watched: () => void,
|
|
97
|
+
unwatched?: () => void,
|
|
98
|
+
) => {
|
|
99
|
+
watchedCallbackMap.set(signal, watched)
|
|
100
|
+
if (unwatched) unwatchedCallbackMap.set(signal, unwatched)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Subscribe active watcher to a signal.
|
|
105
|
+
*
|
|
106
|
+
* @param {UnknownSignal} signal - Signal to subscribe to
|
|
107
|
+
* @returns {boolean} - true if the active watcher was subscribed,
|
|
108
|
+
* false if the watcher was already subscribed or there was no active watcher
|
|
109
|
+
*/
|
|
110
|
+
const subscribeTo = (signal: UnknownSignal): boolean => {
|
|
111
|
+
if (!activeWatcher || watchersMap.get(signal)?.has(activeWatcher))
|
|
112
|
+
return false
|
|
113
|
+
|
|
114
|
+
const watcher = activeWatcher
|
|
115
|
+
if (!watchersMap.has(signal)) watchersMap.set(signal, new Set<Watcher>())
|
|
116
|
+
|
|
117
|
+
const watchers = watchersMap.get(signal)
|
|
118
|
+
assert(watchers)
|
|
119
|
+
if (!watchers.size) {
|
|
120
|
+
const watchedCallback = watchedCallbackMap.get(signal)
|
|
121
|
+
if (watchedCallback) untrack(watchedCallback)
|
|
122
|
+
}
|
|
123
|
+
watchers.add(watcher)
|
|
124
|
+
watcher.onCleanup(() => {
|
|
125
|
+
watchers.delete(watcher)
|
|
126
|
+
if (!watchers.size) {
|
|
127
|
+
const unwatchedCallback = unwatchedCallbackMap.get(signal)
|
|
128
|
+
if (unwatchedCallback) untrack(unwatchedCallback)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
64
134
|
const subscribeActiveWatcher = (watchers: Set<Watcher>) => {
|
|
65
|
-
if (activeWatcher
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
135
|
+
if (!activeWatcher || watchers.has(activeWatcher)) return false
|
|
136
|
+
|
|
137
|
+
const watcher = activeWatcher
|
|
138
|
+
watchers.add(watcher)
|
|
139
|
+
if (!watchers.size) {
|
|
140
|
+
const watchedCallback = watchedCallbackMap.get(watchers)
|
|
141
|
+
if (watchedCallback) untrack(watchedCallback)
|
|
69
142
|
}
|
|
143
|
+
watcher.onCleanup(() => {
|
|
144
|
+
watchers.delete(watcher)
|
|
145
|
+
if (!watchers.size) {
|
|
146
|
+
const unwatchedCallback = unwatchedCallbackMap.get(watchers)
|
|
147
|
+
if (unwatchedCallback) untrack(unwatchedCallback)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
return true
|
|
70
151
|
}
|
|
71
152
|
|
|
72
153
|
/**
|
|
73
|
-
*
|
|
154
|
+
* Unsubscribe all watchers from a signal so it can be garbage collected.
|
|
74
155
|
*
|
|
75
|
-
* @param {
|
|
156
|
+
* @param {UnknownSignal} signal - Signal to unsubscribe from
|
|
157
|
+
* @returns {void}
|
|
76
158
|
*/
|
|
77
|
-
const
|
|
159
|
+
const unsubscribeAllFrom = (signal: UnknownSignal): void => {
|
|
160
|
+
const watchers = watchersMap.get(signal)
|
|
161
|
+
if (!watchers) return
|
|
162
|
+
|
|
163
|
+
for (const watcher of watchers) watcher.stop()
|
|
164
|
+
watchers.clear()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Notify watchers of a signal change.
|
|
169
|
+
*
|
|
170
|
+
* @param {UnknownSignal} signal - Signal to notify watchers of
|
|
171
|
+
* @returns {boolean} - Whether any watchers were notified
|
|
172
|
+
*/
|
|
173
|
+
const notifyOf = (signal: UnknownSignal): boolean => {
|
|
174
|
+
const watchers = watchersMap.get(signal)
|
|
175
|
+
if (!watchers?.size) return false
|
|
176
|
+
|
|
78
177
|
for (const react of watchers) {
|
|
79
178
|
if (batchDepth) pendingReactions.add(react)
|
|
80
179
|
else react()
|
|
81
180
|
}
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const notifyWatchers = (watchers: Set<Watcher>): boolean => {
|
|
185
|
+
if (!watchers.size) return false
|
|
186
|
+
|
|
187
|
+
for (const react of watchers) {
|
|
188
|
+
if (batchDepth) pendingReactions.add(react)
|
|
189
|
+
else react()
|
|
190
|
+
}
|
|
191
|
+
return true
|
|
82
192
|
}
|
|
83
193
|
|
|
84
194
|
/**
|
|
85
|
-
* Flush all pending reactions of enqueued watchers
|
|
195
|
+
* Flush all pending reactions of enqueued watchers.
|
|
86
196
|
*/
|
|
87
|
-
const
|
|
197
|
+
const flush = () => {
|
|
88
198
|
while (pendingReactions.size) {
|
|
89
199
|
const watchers = Array.from(pendingReactions)
|
|
90
200
|
pendingReactions.clear()
|
|
91
|
-
for (const
|
|
201
|
+
for (const react of watchers) react()
|
|
92
202
|
}
|
|
93
203
|
}
|
|
94
204
|
|
|
95
205
|
/**
|
|
96
|
-
* Batch multiple signal writes
|
|
206
|
+
* Batch multiple signal writes.
|
|
97
207
|
*
|
|
98
208
|
* @param {() => void} callback - Function with multiple signal writes to be batched
|
|
99
209
|
*/
|
|
100
|
-
const
|
|
210
|
+
const batch = (callback: () => void) => {
|
|
101
211
|
batchDepth++
|
|
102
212
|
try {
|
|
103
213
|
callback()
|
|
104
214
|
} finally {
|
|
105
|
-
|
|
215
|
+
flush()
|
|
106
216
|
batchDepth--
|
|
107
217
|
}
|
|
108
218
|
}
|
|
109
219
|
|
|
110
220
|
/**
|
|
111
|
-
* Run a function with signal reads in a tracking context (or temporarily untrack)
|
|
221
|
+
* Run a function with signal reads in a tracking context (or temporarily untrack).
|
|
112
222
|
*
|
|
113
223
|
* @param {Watcher | false} watcher - Watcher to be called when the signal changes
|
|
114
224
|
* or false for temporary untracking while inserting auto-hydrating DOM nodes
|
|
115
225
|
* that might read signals (e.g., Web Components)
|
|
116
226
|
* @param {() => void} run - Function to run the computation or effect
|
|
117
227
|
*/
|
|
118
|
-
const
|
|
228
|
+
const track = (watcher: Watcher | false, run: () => void): void => {
|
|
119
229
|
const prev = activeWatcher
|
|
120
230
|
activeWatcher = watcher || undefined
|
|
121
231
|
try {
|
|
@@ -125,35 +235,23 @@ const trackSignalReads = (watcher: Watcher | false, run: () => void): void => {
|
|
|
125
235
|
}
|
|
126
236
|
}
|
|
127
237
|
|
|
128
|
-
/**
|
|
129
|
-
* Emit a notification to listeners
|
|
130
|
-
*
|
|
131
|
-
* @param {Set<Listener>} listeners - Listeners to be notified
|
|
132
|
-
* @param {Notifications[K]} payload - Payload to be sent to listeners
|
|
133
|
-
*/
|
|
134
|
-
const emitNotification = <T extends keyof Notifications>(
|
|
135
|
-
listeners: Set<Listener<T>>,
|
|
136
|
-
payload: Notifications[T],
|
|
137
|
-
) => {
|
|
138
|
-
for (const listener of listeners) {
|
|
139
|
-
if (batchDepth) pendingReactions.add(() => listener(payload))
|
|
140
|
-
else listener(payload)
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
238
|
/* === Exports === */
|
|
145
239
|
|
|
146
240
|
export {
|
|
147
241
|
type Cleanup,
|
|
242
|
+
type MaybeCleanup,
|
|
148
243
|
type Watcher,
|
|
149
|
-
type
|
|
150
|
-
|
|
151
|
-
type Listeners,
|
|
244
|
+
type SignalOptions,
|
|
245
|
+
UNSET,
|
|
152
246
|
createWatcher,
|
|
247
|
+
registerWatchCallbacks,
|
|
248
|
+
subscribeTo,
|
|
153
249
|
subscribeActiveWatcher,
|
|
250
|
+
unsubscribeAllFrom,
|
|
251
|
+
notifyOf,
|
|
154
252
|
notifyWatchers,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
253
|
+
flush,
|
|
254
|
+
batch,
|
|
255
|
+
track,
|
|
256
|
+
untrack,
|
|
159
257
|
}
|
package/src/util.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/* === Constants === */
|
|
2
|
-
|
|
3
|
-
// biome-ignore lint/suspicious/noExplicitAny: Deliberately using any to be used as a placeholder value in any signal
|
|
4
|
-
const UNSET: any = Symbol()
|
|
5
|
-
|
|
6
1
|
/* === Utility Functions === */
|
|
7
2
|
|
|
8
3
|
const isString = /*#__PURE__*/ (value: unknown): value is string =>
|
|
@@ -73,7 +68,6 @@ const valueString = /*#__PURE__*/ (value: unknown): string =>
|
|
|
73
68
|
/* === Exports === */
|
|
74
69
|
|
|
75
70
|
export {
|
|
76
|
-
UNSET,
|
|
77
71
|
isString,
|
|
78
72
|
isNumber,
|
|
79
73
|
isSymbol,
|
package/test/batch.test.ts
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
3
|
-
batchSignalWrites,
|
|
4
|
-
createEffect,
|
|
5
|
-
Memo,
|
|
6
|
-
match,
|
|
7
|
-
resolve,
|
|
8
|
-
State,
|
|
9
|
-
} from '../index.ts'
|
|
2
|
+
import { batch, createEffect, Memo, match, resolve, State } from '../index.ts'
|
|
10
3
|
|
|
11
4
|
/* === Tests === */
|
|
12
5
|
|
|
@@ -19,7 +12,7 @@ describe('Batch', () => {
|
|
|
19
12
|
result = cause.get()
|
|
20
13
|
count++
|
|
21
14
|
})
|
|
22
|
-
|
|
15
|
+
batch(() => {
|
|
23
16
|
for (let i = 1; i <= 10; i++) cause.set(i)
|
|
24
17
|
})
|
|
25
18
|
expect(result).toBe(10)
|
|
@@ -43,7 +36,7 @@ describe('Batch', () => {
|
|
|
43
36
|
err: () => {},
|
|
44
37
|
})
|
|
45
38
|
})
|
|
46
|
-
|
|
39
|
+
batch(() => {
|
|
47
40
|
a.set(6)
|
|
48
41
|
b.set(8)
|
|
49
42
|
c.set(10)
|
|
@@ -87,7 +80,7 @@ describe('Batch', () => {
|
|
|
87
80
|
expect(result).toBe(10)
|
|
88
81
|
|
|
89
82
|
// Batch: apply changes to all signals in a single transaction
|
|
90
|
-
|
|
83
|
+
batch(() => {
|
|
91
84
|
signals.forEach(signal => signal.update(v => v * 2))
|
|
92
85
|
})
|
|
93
86
|
|
package/test/benchmark.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import { batch, createEffect, Memo, State } from '../index.ts'
|
|
3
3
|
import { Counter, makeGraph, runGraph } from './util/dependency-graph'
|
|
4
4
|
import type { Computed, ReactiveFramework } from './util/reactive-framework'
|
|
5
5
|
|
|
@@ -28,7 +28,7 @@ const framework = {
|
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
30
|
effect: (fn: () => undefined) => createEffect(fn),
|
|
31
|
-
withBatch: (fn: () => undefined) =>
|
|
31
|
+
withBatch: (fn: () => undefined) => batch(fn),
|
|
32
32
|
withBuild: <T>(fn: () => T) => fn(),
|
|
33
33
|
}
|
|
34
34
|
const testPullCounts = true
|
|
@@ -449,6 +449,7 @@ describe('$mol_wire tests', () => {
|
|
|
449
449
|
const name = framework.name
|
|
450
450
|
|
|
451
451
|
test(`${name} | $mol_wire benchmark`, () => {
|
|
452
|
+
// @ts-expect-error test
|
|
452
453
|
const fib = (n: number) => {
|
|
453
454
|
if (n < 2) return 1
|
|
454
455
|
return fib(n - 1) + fib(n - 2)
|
|
@@ -618,6 +619,7 @@ describe('CellX tests', () => {
|
|
|
618
619
|
for (const layers in expected) {
|
|
619
620
|
// @ts-expect-error - Framework object has incompatible type constraints with ReactiveFramework
|
|
620
621
|
const [before, after] = cellx(framework, layers)
|
|
622
|
+
// @ts-expect-error - Framework object has incompatible type constraints with ReactiveFramework
|
|
621
623
|
const [expectedBefore, expectedAfter] = expected[layers]
|
|
622
624
|
expect(before.toString()).toBe(expectedBefore.toString())
|
|
623
625
|
expect(after.toString()).toBe(expectedAfter.toString())
|
package/test/collection.test.ts
CHANGED
|
@@ -104,7 +104,7 @@ describe('collection', () => {
|
|
|
104
104
|
test('returns undefined for non-existent properties', () => {
|
|
105
105
|
const items = new List([1, 2])
|
|
106
106
|
const collection = new DerivedCollection(items, (x: number) => x)
|
|
107
|
-
expect(collection
|
|
107
|
+
expect(collection.at(5)).toBeUndefined()
|
|
108
108
|
})
|
|
109
109
|
|
|
110
110
|
test('supports numeric key access', () => {
|
|
@@ -230,50 +230,6 @@ describe('collection', () => {
|
|
|
230
230
|
})
|
|
231
231
|
})
|
|
232
232
|
|
|
233
|
-
describe('change notifications', () => {
|
|
234
|
-
test('emits add notifications', () => {
|
|
235
|
-
const numbers = new List([1, 2])
|
|
236
|
-
const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
|
|
237
|
-
|
|
238
|
-
let arrayAddNotification: readonly string[] = []
|
|
239
|
-
doubled.on('add', keys => {
|
|
240
|
-
arrayAddNotification = keys
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
numbers.add(3)
|
|
244
|
-
expect(arrayAddNotification).toHaveLength(1)
|
|
245
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
246
|
-
expect(doubled.byKey(arrayAddNotification[0]!)?.get()).toBe(6)
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
test('emits remove notifications when items are removed', () => {
|
|
250
|
-
const items = new List([1, 2, 3])
|
|
251
|
-
const doubled = new DerivedCollection(items, (x: number) => x * 2)
|
|
252
|
-
|
|
253
|
-
let arrayRemoveNotification: readonly string[] = []
|
|
254
|
-
doubled.on('remove', keys => {
|
|
255
|
-
arrayRemoveNotification = keys
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
items.remove(1)
|
|
259
|
-
expect(arrayRemoveNotification).toHaveLength(1)
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
test('emits sort notifications when source is sorted', () => {
|
|
263
|
-
const numbers = new List([3, 1, 2])
|
|
264
|
-
const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
|
|
265
|
-
|
|
266
|
-
let sortNotification: readonly string[] = []
|
|
267
|
-
doubled.on('sort', newOrder => {
|
|
268
|
-
sortNotification = newOrder
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
numbers.sort((a, b) => a - b)
|
|
272
|
-
expect(sortNotification).toHaveLength(3)
|
|
273
|
-
expect(doubled.get()).toEqual([2, 4, 6])
|
|
274
|
-
})
|
|
275
|
-
})
|
|
276
|
-
|
|
277
233
|
describe('edge cases', () => {
|
|
278
234
|
test('handles empty collections correctly', () => {
|
|
279
235
|
const empty = new List<number>([])
|
|
@@ -721,68 +677,6 @@ describe('collection', () => {
|
|
|
721
677
|
})
|
|
722
678
|
})
|
|
723
679
|
|
|
724
|
-
describe('derived collection event handling', () => {
|
|
725
|
-
test('emits add events when source adds items', () => {
|
|
726
|
-
const numbers = new List([1, 2])
|
|
727
|
-
const doubled = new DerivedCollection(
|
|
728
|
-
numbers,
|
|
729
|
-
(x: number) => x * 2,
|
|
730
|
-
)
|
|
731
|
-
const quadrupled = doubled.deriveCollection(
|
|
732
|
-
(x: number) => x * 2,
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
let addedKeys: readonly string[] = []
|
|
736
|
-
quadrupled.on('add', keys => {
|
|
737
|
-
addedKeys = keys
|
|
738
|
-
})
|
|
739
|
-
|
|
740
|
-
numbers.add(3)
|
|
741
|
-
expect(addedKeys).toHaveLength(1)
|
|
742
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
743
|
-
expect(quadrupled.byKey(addedKeys[0]!)?.get()).toBe(12)
|
|
744
|
-
})
|
|
745
|
-
|
|
746
|
-
test('emits remove events when source removes items', () => {
|
|
747
|
-
const numbers = new List([1, 2, 3])
|
|
748
|
-
const doubled = new DerivedCollection(
|
|
749
|
-
numbers,
|
|
750
|
-
(x: number) => x * 2,
|
|
751
|
-
)
|
|
752
|
-
const quadrupled = doubled.deriveCollection(
|
|
753
|
-
(x: number) => x * 2,
|
|
754
|
-
)
|
|
755
|
-
|
|
756
|
-
let removedKeys: readonly string[] = []
|
|
757
|
-
quadrupled.on('remove', keys => {
|
|
758
|
-
removedKeys = keys
|
|
759
|
-
})
|
|
760
|
-
|
|
761
|
-
numbers.remove(1)
|
|
762
|
-
expect(removedKeys).toHaveLength(1)
|
|
763
|
-
})
|
|
764
|
-
|
|
765
|
-
test('emits sort events when source is sorted', () => {
|
|
766
|
-
const numbers = new List([3, 1, 2])
|
|
767
|
-
const doubled = new DerivedCollection(
|
|
768
|
-
numbers,
|
|
769
|
-
(x: number) => x * 2,
|
|
770
|
-
)
|
|
771
|
-
const quadrupled = doubled.deriveCollection(
|
|
772
|
-
(x: number) => x * 2,
|
|
773
|
-
)
|
|
774
|
-
|
|
775
|
-
let sortedKeys: readonly string[] = []
|
|
776
|
-
quadrupled.on('sort', newOrder => {
|
|
777
|
-
sortedKeys = newOrder
|
|
778
|
-
})
|
|
779
|
-
|
|
780
|
-
numbers.sort((a, b) => a - b)
|
|
781
|
-
expect(sortedKeys).toHaveLength(3)
|
|
782
|
-
expect(quadrupled.get()).toEqual([4, 8, 12])
|
|
783
|
-
})
|
|
784
|
-
})
|
|
785
|
-
|
|
786
680
|
describe('edge cases', () => {
|
|
787
681
|
test('handles empty collection derivation', () => {
|
|
788
682
|
const empty = new List<number>([])
|
|
@@ -850,4 +744,109 @@ describe('collection', () => {
|
|
|
850
744
|
})
|
|
851
745
|
})
|
|
852
746
|
})
|
|
747
|
+
|
|
748
|
+
describe('Watch Callbacks', () => {
|
|
749
|
+
test('Collection watched callback is called when effect accesses collection.get()', () => {
|
|
750
|
+
const numbers = new List([10, 20, 30])
|
|
751
|
+
|
|
752
|
+
let collectionWatchedCalled = false
|
|
753
|
+
let collectionUnwatchCalled = false
|
|
754
|
+
const doubled = numbers.deriveCollection(x => x * 2, {
|
|
755
|
+
watched: () => {
|
|
756
|
+
collectionWatchedCalled = true
|
|
757
|
+
},
|
|
758
|
+
unwatched: () => {
|
|
759
|
+
collectionUnwatchCalled = true
|
|
760
|
+
},
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
expect(collectionWatchedCalled).toBe(false)
|
|
764
|
+
|
|
765
|
+
// Access collection via collection.get() - this should trigger collection's watched callback
|
|
766
|
+
let effectValue: number[] = []
|
|
767
|
+
const cleanup = createEffect(() => {
|
|
768
|
+
effectValue = doubled.get()
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
expect(collectionWatchedCalled).toBe(true)
|
|
772
|
+
expect(effectValue).toEqual([20, 40, 60])
|
|
773
|
+
expect(collectionUnwatchCalled).toBe(false)
|
|
774
|
+
|
|
775
|
+
// Cleanup effect - should trigger unwatch
|
|
776
|
+
cleanup()
|
|
777
|
+
expect(collectionUnwatchCalled).toBe(true)
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
test('Collection and List watched callbacks work independently', () => {
|
|
781
|
+
let sourceWatchedCalled = false
|
|
782
|
+
const items = new List(['hello', 'world'], {
|
|
783
|
+
watched: () => {
|
|
784
|
+
sourceWatchedCalled = true
|
|
785
|
+
},
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
let collectionWatchedCalled = false
|
|
789
|
+
let collectionUnwatchedCalled = false
|
|
790
|
+
const uppercased = items.deriveCollection(x => x.toUpperCase(), {
|
|
791
|
+
watched: () => {
|
|
792
|
+
collectionWatchedCalled = true
|
|
793
|
+
},
|
|
794
|
+
unwatched: () => {
|
|
795
|
+
collectionUnwatchedCalled = true
|
|
796
|
+
},
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
// Effect 1: Access collection-level data - triggers both watched callbacks
|
|
800
|
+
let collectionValue: string[] = []
|
|
801
|
+
const collectionCleanup = createEffect(() => {
|
|
802
|
+
collectionValue = uppercased.get()
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
expect(collectionWatchedCalled).toBe(true)
|
|
806
|
+
expect(sourceWatchedCalled).toBe(true) // Source items accessed by collection.get()
|
|
807
|
+
expect(collectionValue).toEqual(['HELLO', 'WORLD'])
|
|
808
|
+
|
|
809
|
+
// Effect 2: Access individual collection item independently
|
|
810
|
+
let itemValue: string | undefined
|
|
811
|
+
const itemCleanup = createEffect(() => {
|
|
812
|
+
itemValue = uppercased.at(0)?.get()
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
expect(itemValue).toBe('HELLO')
|
|
816
|
+
|
|
817
|
+
// Clean up effects
|
|
818
|
+
collectionCleanup()
|
|
819
|
+
expect(collectionUnwatchedCalled).toBe(true)
|
|
820
|
+
|
|
821
|
+
itemCleanup()
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
test('Collection length access triggers Collection watched callback', () => {
|
|
825
|
+
const numbers = new List([1, 2, 3])
|
|
826
|
+
|
|
827
|
+
let collectionWatchedCalled = false
|
|
828
|
+
let collectionUnwatchedCalled = false
|
|
829
|
+
const doubled = numbers.deriveCollection(x => x * 2, {
|
|
830
|
+
watched: () => {
|
|
831
|
+
collectionWatchedCalled = true
|
|
832
|
+
},
|
|
833
|
+
unwatched: () => {
|
|
834
|
+
collectionUnwatchedCalled = true
|
|
835
|
+
},
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
// Access via collection.length - this should trigger collection's watched callback
|
|
839
|
+
let effectValue: number = 0
|
|
840
|
+
const cleanup = createEffect(() => {
|
|
841
|
+
effectValue = doubled.length
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
expect(collectionWatchedCalled).toBe(true)
|
|
845
|
+
expect(effectValue).toBe(3)
|
|
846
|
+
expect(collectionUnwatchedCalled).toBe(false)
|
|
847
|
+
|
|
848
|
+
cleanup()
|
|
849
|
+
expect(collectionUnwatchedCalled).toBe(true)
|
|
850
|
+
})
|
|
851
|
+
})
|
|
853
852
|
})
|