@zeix/cause-effect 0.18.2 → 0.18.4
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 +12 -4
- package/.github/copilot-instructions.md +10 -2
- package/ARCHITECTURE.md +21 -9
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +28 -4
- package/GUIDE.md +22 -0
- package/README.md +33 -1
- package/REQUIREMENTS.md +4 -3
- package/index.dev.js +178 -81
- package/index.js +1 -1
- package/index.ts +3 -1
- package/package.json +1 -1
- package/src/errors.ts +13 -0
- package/src/graph.ts +8 -4
- package/src/nodes/collection.ts +0 -4
- package/src/nodes/effect.ts +3 -3
- package/src/nodes/memo.ts +1 -3
- package/src/nodes/slot.ts +134 -0
- package/src/nodes/task.ts +1 -3
- package/src/signal.ts +2 -0
- package/test/effect.test.ts +221 -0
- package/test/signal.test.ts +2 -0
- package/test/slot.test.ts +118 -0
- package/types/index.d.ts +3 -2
- package/types/src/errors.d.ts +9 -1
- package/types/src/graph.d.ts +4 -1
- package/types/src/nodes/effect.d.ts +2 -2
- package/types/src/nodes/slot.d.ts +53 -0
package/src/graph.ts
CHANGED
|
@@ -156,6 +156,7 @@ const TYPE_SENSOR = 'Sensor'
|
|
|
156
156
|
const TYPE_LIST = 'List'
|
|
157
157
|
const TYPE_COLLECTION = 'Collection'
|
|
158
158
|
const TYPE_STORE = 'Store'
|
|
159
|
+
const TYPE_SLOT = 'Slot'
|
|
159
160
|
|
|
160
161
|
const FLAG_CLEAN = 0
|
|
161
162
|
const FLAG_CHECK = 1 << 0
|
|
@@ -291,11 +292,12 @@ function propagate(node: SinkNode, newFlag = FLAG_DIRTY): void {
|
|
|
291
292
|
for (let e = node.sinks; e; e = e.nextSink)
|
|
292
293
|
propagate(e.sink, FLAG_CHECK)
|
|
293
294
|
} else {
|
|
294
|
-
if (flags & FLAG_DIRTY) return
|
|
295
|
+
if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) return
|
|
295
296
|
|
|
296
297
|
// Enqueue effect for later execution
|
|
297
|
-
|
|
298
|
-
|
|
298
|
+
const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK)
|
|
299
|
+
node.flags = newFlag
|
|
300
|
+
if (!wasQueued) queuedEffects.push(node as EffectNode)
|
|
299
301
|
}
|
|
300
302
|
}
|
|
301
303
|
|
|
@@ -470,7 +472,7 @@ function flush(): void {
|
|
|
470
472
|
try {
|
|
471
473
|
for (let i = 0; i < queuedEffects.length; i++) {
|
|
472
474
|
const effect = queuedEffects[i]
|
|
473
|
-
if (effect.flags & FLAG_DIRTY) refresh(effect)
|
|
475
|
+
if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) refresh(effect)
|
|
474
476
|
}
|
|
475
477
|
queuedEffects.length = 0
|
|
476
478
|
} finally {
|
|
@@ -600,6 +602,7 @@ export {
|
|
|
600
602
|
createScope,
|
|
601
603
|
DEFAULT_EQUALITY,
|
|
602
604
|
SKIP_EQUALITY,
|
|
605
|
+
FLAG_CHECK,
|
|
603
606
|
FLAG_CLEAN,
|
|
604
607
|
FLAG_DIRTY,
|
|
605
608
|
FLAG_RELINK,
|
|
@@ -617,6 +620,7 @@ export {
|
|
|
617
620
|
TYPE_MEMO,
|
|
618
621
|
TYPE_SENSOR,
|
|
619
622
|
TYPE_STATE,
|
|
623
|
+
TYPE_SLOT,
|
|
620
624
|
TYPE_STORE,
|
|
621
625
|
TYPE_TASK,
|
|
622
626
|
unlink,
|
package/src/nodes/collection.ts
CHANGED
|
@@ -100,10 +100,6 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
100
100
|
callback: DeriveCollectionCallback<T, U>,
|
|
101
101
|
): Collection<T> {
|
|
102
102
|
validateCallback(TYPE_COLLECTION, callback)
|
|
103
|
-
if (!isCollectionSource(source))
|
|
104
|
-
throw new TypeError(
|
|
105
|
-
`[${TYPE_COLLECTION}] Invalid collection source: expected a List or Collection`,
|
|
106
|
-
)
|
|
107
103
|
|
|
108
104
|
const isAsync = isAsyncFunction(callback)
|
|
109
105
|
const signals = new Map<string, Memo<T>>()
|
package/src/nodes/effect.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
|
|
23
23
|
type MaybePromise<T> = T | Promise<T>
|
|
24
24
|
|
|
25
|
-
type MatchHandlers<T extends Signal<unknown & {}>[]> = {
|
|
25
|
+
type MatchHandlers<T extends readonly Signal<unknown & {}>[]> = {
|
|
26
26
|
ok: (values: {
|
|
27
27
|
[K in keyof T]: T[K] extends Signal<infer V> ? V : never
|
|
28
28
|
}) => MaybePromise<MaybeCleanup>
|
|
@@ -94,8 +94,8 @@ function createEffect(fn: EffectCallback): Cleanup {
|
|
|
94
94
|
* @since 0.15.0
|
|
95
95
|
* @throws RequiredOwnerError If called without an active owner.
|
|
96
96
|
*/
|
|
97
|
-
function match<T extends Signal<unknown & {}>[]>(
|
|
98
|
-
signals: T,
|
|
97
|
+
function match<T extends readonly Signal<unknown & {}>[]>(
|
|
98
|
+
signals: readonly [...T],
|
|
99
99
|
handlers: MatchHandlers<T>,
|
|
100
100
|
): MaybeCleanup {
|
|
101
101
|
if (!activeOwner) throw new RequiredOwnerError('match')
|
package/src/nodes/memo.ts
CHANGED
|
@@ -110,9 +110,7 @@ function createMemo<T extends {}>(
|
|
|
110
110
|
if (activeSink) {
|
|
111
111
|
if (!node.sinks)
|
|
112
112
|
node.stop = watched(() => {
|
|
113
|
-
node
|
|
114
|
-
for (let e = node.sinks; e; e = e.nextSink)
|
|
115
|
-
propagate(e.sink)
|
|
113
|
+
propagate(node as unknown as SinkNode)
|
|
116
114
|
if (batchDepth === 0) flush()
|
|
117
115
|
})
|
|
118
116
|
link(node, activeSink)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ReadonlySignalError, validateSignalValue } from '../errors'
|
|
2
|
+
import {
|
|
3
|
+
activeSink,
|
|
4
|
+
batchDepth,
|
|
5
|
+
DEFAULT_EQUALITY,
|
|
6
|
+
FLAG_DIRTY,
|
|
7
|
+
flush,
|
|
8
|
+
link,
|
|
9
|
+
type MemoNode,
|
|
10
|
+
propagate,
|
|
11
|
+
refresh,
|
|
12
|
+
type Signal,
|
|
13
|
+
type SignalOptions,
|
|
14
|
+
type SinkNode,
|
|
15
|
+
TYPE_SLOT,
|
|
16
|
+
} from '../graph'
|
|
17
|
+
import { isMutableSignal, isSignal } from '../signal'
|
|
18
|
+
import { isObjectOfType } from '../util'
|
|
19
|
+
|
|
20
|
+
/* === Types === */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A signal that delegates its value to a swappable backing signal.
|
|
24
|
+
*
|
|
25
|
+
* Slots provide a stable reactive source at a fixed position (e.g. an object property)
|
|
26
|
+
* while allowing the backing signal to be replaced without breaking subscribers.
|
|
27
|
+
* The object shape is compatible with `Object.defineProperty()` descriptors:
|
|
28
|
+
* `get`, `set`, `configurable`, and `enumerable` are used by the property definition;
|
|
29
|
+
* `replace()` and `current()` are kept on the slot object for integration-layer control.
|
|
30
|
+
*
|
|
31
|
+
* @template T - The type of value held by the delegated signal.
|
|
32
|
+
*/
|
|
33
|
+
type Slot<T extends {}> = {
|
|
34
|
+
readonly [Symbol.toStringTag]: 'Slot'
|
|
35
|
+
/** Descriptor field: allows the property to be redefined or deleted. */
|
|
36
|
+
configurable: true
|
|
37
|
+
/** Descriptor field: the property shows up during enumeration. */
|
|
38
|
+
enumerable: true
|
|
39
|
+
/** Reads the current value from the delegated signal, tracking dependencies. */
|
|
40
|
+
get(): T
|
|
41
|
+
/** Writes a value to the delegated signal. Throws `ReadonlySignalError` if the delegated signal is read-only. */
|
|
42
|
+
set(next: T): void
|
|
43
|
+
/** Swaps the backing signal, invalidating all downstream subscribers. Narrowing (`U extends T`) is allowed. */
|
|
44
|
+
replace<U extends T>(next: Signal<U>): void
|
|
45
|
+
/** Returns the currently delegated signal. */
|
|
46
|
+
current(): Signal<T>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* === Exported Functions === */
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a slot signal that delegates its value to a swappable backing signal.
|
|
53
|
+
*
|
|
54
|
+
* A slot acts as a stable reactive source that can be used as a property descriptor
|
|
55
|
+
* via `Object.defineProperty(target, key, slot)`. Subscribers link to the slot itself,
|
|
56
|
+
* so replacing the backing signal with `replace()` invalidates them without breaking
|
|
57
|
+
* existing edges. Setter calls forward to the current backing signal when it is writable.
|
|
58
|
+
*
|
|
59
|
+
* @since 0.18.3
|
|
60
|
+
* @template T - The type of value held by the delegated signal.
|
|
61
|
+
* @param initialSignal - The initial signal to delegate to.
|
|
62
|
+
* @param options - Optional configuration for the slot.
|
|
63
|
+
* @param options.equals - Custom equality function. Defaults to strict equality (`===`).
|
|
64
|
+
* @param options.guard - Type guard to validate values passed to `set()`.
|
|
65
|
+
* @returns A `Slot<T>` object usable both as a property descriptor and as a reactive signal.
|
|
66
|
+
*/
|
|
67
|
+
function createSlot<T extends {}>(
|
|
68
|
+
initialSignal: Signal<T>,
|
|
69
|
+
options?: SignalOptions<T>,
|
|
70
|
+
): Slot<T> {
|
|
71
|
+
validateSignalValue(TYPE_SLOT, initialSignal, isSignal)
|
|
72
|
+
|
|
73
|
+
let delegated = initialSignal as Signal<T>
|
|
74
|
+
const guard = options?.guard
|
|
75
|
+
|
|
76
|
+
const node: MemoNode<T> = {
|
|
77
|
+
fn: () => delegated.get(),
|
|
78
|
+
value: undefined as unknown as T,
|
|
79
|
+
flags: FLAG_DIRTY,
|
|
80
|
+
sources: null,
|
|
81
|
+
sourcesTail: null,
|
|
82
|
+
sinks: null,
|
|
83
|
+
sinksTail: null,
|
|
84
|
+
equals: options?.equals ?? DEFAULT_EQUALITY,
|
|
85
|
+
error: undefined,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const get = (): T => {
|
|
89
|
+
if (activeSink) link(node, activeSink)
|
|
90
|
+
refresh(node as unknown as SinkNode)
|
|
91
|
+
if (node.error) throw node.error
|
|
92
|
+
return node.value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const set = (next: T): void => {
|
|
96
|
+
if (!isMutableSignal(delegated))
|
|
97
|
+
throw new ReadonlySignalError(TYPE_SLOT)
|
|
98
|
+
validateSignalValue(TYPE_SLOT, next, guard)
|
|
99
|
+
|
|
100
|
+
delegated.set(next)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const replace = <U extends T>(next: Signal<U>): void => {
|
|
104
|
+
validateSignalValue(TYPE_SLOT, next, isSignal)
|
|
105
|
+
|
|
106
|
+
delegated = next
|
|
107
|
+
node.flags |= FLAG_DIRTY
|
|
108
|
+
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
|
|
109
|
+
if (batchDepth === 0) flush()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
[Symbol.toStringTag]: TYPE_SLOT,
|
|
114
|
+
configurable: true,
|
|
115
|
+
enumerable: true,
|
|
116
|
+
get,
|
|
117
|
+
set,
|
|
118
|
+
replace,
|
|
119
|
+
current: () => delegated,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Checks if a value is a Slot signal.
|
|
125
|
+
*
|
|
126
|
+
* @since 0.18.3
|
|
127
|
+
* @param value - The value to check
|
|
128
|
+
* @returns True if the value is a Slot
|
|
129
|
+
*/
|
|
130
|
+
function isSlot<T extends {} = unknown & {}>(value: unknown): value is Slot<T> {
|
|
131
|
+
return isObjectOfType(value, TYPE_SLOT)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { createSlot, isSlot, type Slot }
|
package/src/nodes/task.ts
CHANGED
|
@@ -129,9 +129,7 @@ function createTask<T extends {}>(
|
|
|
129
129
|
if (activeSink) {
|
|
130
130
|
if (!node.sinks)
|
|
131
131
|
node.stop = watched(() => {
|
|
132
|
-
node
|
|
133
|
-
for (let e = node.sinks; e; e = e.nextSink)
|
|
134
|
-
propagate(e.sink)
|
|
132
|
+
propagate(node as unknown as SinkNode)
|
|
135
133
|
if (batchDepth === 0) flush()
|
|
136
134
|
})
|
|
137
135
|
link(node, activeSink)
|
package/src/signal.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TYPE_LIST,
|
|
9
9
|
TYPE_MEMO,
|
|
10
10
|
TYPE_SENSOR,
|
|
11
|
+
TYPE_SLOT,
|
|
11
12
|
TYPE_STATE,
|
|
12
13
|
TYPE_STORE,
|
|
13
14
|
TYPE_TASK,
|
|
@@ -122,6 +123,7 @@ function isSignal<T extends {}>(value: unknown): value is Signal<T> {
|
|
|
122
123
|
TYPE_MEMO,
|
|
123
124
|
TYPE_TASK,
|
|
124
125
|
TYPE_SENSOR,
|
|
126
|
+
TYPE_SLOT,
|
|
125
127
|
TYPE_LIST,
|
|
126
128
|
TYPE_COLLECTION,
|
|
127
129
|
TYPE_STORE,
|
package/test/effect.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
|
+
batch,
|
|
3
4
|
createEffect,
|
|
4
5
|
createMemo,
|
|
5
6
|
createScope,
|
|
@@ -140,6 +141,209 @@ describe('createEffect', () => {
|
|
|
140
141
|
})
|
|
141
142
|
})
|
|
142
143
|
|
|
144
|
+
describe('Watched memo equality', () => {
|
|
145
|
+
test('should skip effect re-run when watched memo recomputes to same value', () => {
|
|
146
|
+
let invalidate!: () => void
|
|
147
|
+
let effectCount = 0
|
|
148
|
+
|
|
149
|
+
// Memo whose computed value does not change on invalidation
|
|
150
|
+
const memo = createMemo(() => 42, {
|
|
151
|
+
value: 42,
|
|
152
|
+
watched: inv => {
|
|
153
|
+
invalidate = inv
|
|
154
|
+
return () => {}
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const dispose = createScope(() => {
|
|
159
|
+
createEffect(() => {
|
|
160
|
+
void memo.get()
|
|
161
|
+
effectCount++
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
expect(effectCount).toBe(1)
|
|
166
|
+
|
|
167
|
+
// Invalidate — memo recomputes but returns same value (42)
|
|
168
|
+
invalidate()
|
|
169
|
+
|
|
170
|
+
// Because equals(42, 42) is true, the effect should NOT re-run
|
|
171
|
+
expect(effectCount).toBe(1)
|
|
172
|
+
|
|
173
|
+
dispose()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('should re-run effect when watched memo recomputes to different value', () => {
|
|
177
|
+
let invalidate!: () => void
|
|
178
|
+
let effectCount = 0
|
|
179
|
+
let externalValue = 1
|
|
180
|
+
|
|
181
|
+
const memo = createMemo(() => externalValue, {
|
|
182
|
+
value: 0,
|
|
183
|
+
watched: inv => {
|
|
184
|
+
invalidate = inv
|
|
185
|
+
return () => {}
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
let observed = 0
|
|
190
|
+
const dispose = createScope(() => {
|
|
191
|
+
createEffect(() => {
|
|
192
|
+
observed = memo.get()
|
|
193
|
+
effectCount++
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
expect(effectCount).toBe(1)
|
|
198
|
+
expect(observed).toBe(1)
|
|
199
|
+
|
|
200
|
+
// Change external value and invalidate — memo returns a new value
|
|
201
|
+
externalValue = 99
|
|
202
|
+
invalidate()
|
|
203
|
+
|
|
204
|
+
expect(effectCount).toBe(2)
|
|
205
|
+
expect(observed).toBe(99)
|
|
206
|
+
|
|
207
|
+
dispose()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('should respect custom equals to skip effect re-run', () => {
|
|
211
|
+
let invalidate!: () => void
|
|
212
|
+
let effectCount = 0
|
|
213
|
+
let externalValue = 3
|
|
214
|
+
|
|
215
|
+
// Custom equals: treat values as equal when they round to the same integer
|
|
216
|
+
const memo = createMemo(() => externalValue, {
|
|
217
|
+
value: 0,
|
|
218
|
+
equals: (a, b) => Math.floor(a) === Math.floor(b),
|
|
219
|
+
watched: inv => {
|
|
220
|
+
invalidate = inv
|
|
221
|
+
return () => {}
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const dispose = createScope(() => {
|
|
226
|
+
createEffect(() => {
|
|
227
|
+
void memo.get()
|
|
228
|
+
effectCount++
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(effectCount).toBe(1)
|
|
233
|
+
|
|
234
|
+
// External value changes slightly but rounds to same integer
|
|
235
|
+
externalValue = 3.7
|
|
236
|
+
invalidate()
|
|
237
|
+
expect(effectCount).toBe(1) // equals says same → effect skipped
|
|
238
|
+
|
|
239
|
+
// External value changes to a different integer
|
|
240
|
+
externalValue = 4.1
|
|
241
|
+
invalidate()
|
|
242
|
+
expect(effectCount).toBe(2) // equals says different → effect runs
|
|
243
|
+
|
|
244
|
+
dispose()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('should skip effect re-run through memo chain when watched memo value unchanged', () => {
|
|
248
|
+
let invalidate!: () => void
|
|
249
|
+
let effectCount = 0
|
|
250
|
+
|
|
251
|
+
const watchedMemo = createMemo(() => 42, {
|
|
252
|
+
value: 42,
|
|
253
|
+
watched: inv => {
|
|
254
|
+
invalidate = inv
|
|
255
|
+
return () => {}
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// Downstream memo that doubles the watched memo value
|
|
260
|
+
const doubled = createMemo(() => watchedMemo.get() * 2)
|
|
261
|
+
|
|
262
|
+
const dispose = createScope(() => {
|
|
263
|
+
createEffect(() => {
|
|
264
|
+
void doubled.get()
|
|
265
|
+
effectCount++
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
expect(effectCount).toBe(1)
|
|
270
|
+
|
|
271
|
+
// Invalidate — watchedMemo recomputes to same value, so doubled
|
|
272
|
+
// should also remain unchanged, and the effect should not re-run
|
|
273
|
+
invalidate()
|
|
274
|
+
expect(effectCount).toBe(1)
|
|
275
|
+
|
|
276
|
+
dispose()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('should skip effect when invalidate is called inside batch and value unchanged', () => {
|
|
280
|
+
let invalidate!: () => void
|
|
281
|
+
let effectCount = 0
|
|
282
|
+
|
|
283
|
+
const memo = createMemo(() => 42, {
|
|
284
|
+
value: 42,
|
|
285
|
+
watched: inv => {
|
|
286
|
+
invalidate = inv
|
|
287
|
+
return () => {}
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const dispose = createScope(() => {
|
|
292
|
+
createEffect(() => {
|
|
293
|
+
void memo.get()
|
|
294
|
+
effectCount++
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
expect(effectCount).toBe(1)
|
|
299
|
+
|
|
300
|
+
batch(() => {
|
|
301
|
+
invalidate()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// Value didn't change so effect should still be at 1
|
|
305
|
+
expect(effectCount).toBe(1)
|
|
306
|
+
|
|
307
|
+
dispose()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('should still run effect for dirty state even when watched memo unchanged', () => {
|
|
311
|
+
let invalidate!: () => void
|
|
312
|
+
let effectCount = 0
|
|
313
|
+
const state = createState(1)
|
|
314
|
+
|
|
315
|
+
const memo = createMemo(() => 42, {
|
|
316
|
+
value: 42,
|
|
317
|
+
watched: inv => {
|
|
318
|
+
invalidate = inv
|
|
319
|
+
return () => {}
|
|
320
|
+
},
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
let observedState = 0
|
|
324
|
+
const dispose = createScope(() => {
|
|
325
|
+
createEffect(() => {
|
|
326
|
+
observedState = state.get()
|
|
327
|
+
void memo.get()
|
|
328
|
+
effectCount++
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
expect(effectCount).toBe(1)
|
|
333
|
+
|
|
334
|
+
// Change the state AND invalidate — effect must run because state changed
|
|
335
|
+
batch(() => {
|
|
336
|
+
state.set(2)
|
|
337
|
+
invalidate()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
expect(effectCount).toBe(2)
|
|
341
|
+
expect(observedState).toBe(2)
|
|
342
|
+
|
|
343
|
+
dispose()
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
143
347
|
describe('Input Validation', () => {
|
|
144
348
|
test('should throw InvalidCallbackError for non-function', () => {
|
|
145
349
|
// @ts-expect-error - Testing invalid input
|
|
@@ -249,6 +453,23 @@ describe('match', () => {
|
|
|
249
453
|
}
|
|
250
454
|
})
|
|
251
455
|
|
|
456
|
+
test('should preserve tuple types in ok handler', () => {
|
|
457
|
+
const a = createState(1)
|
|
458
|
+
const b = createState('hello')
|
|
459
|
+
createEffect(() =>
|
|
460
|
+
match([a, b], {
|
|
461
|
+
ok: ([aVal, bVal]) => {
|
|
462
|
+
// If tuple types are preserved, aVal is number and bVal is string
|
|
463
|
+
// If widened, both would be string | number
|
|
464
|
+
const num: number = aVal
|
|
465
|
+
const str: string = bVal
|
|
466
|
+
expect(num).toBe(1)
|
|
467
|
+
expect(str).toBe('hello')
|
|
468
|
+
},
|
|
469
|
+
}),
|
|
470
|
+
)
|
|
471
|
+
})
|
|
472
|
+
|
|
252
473
|
test('should throw RequiredOwnerError when called outside an owner', () => {
|
|
253
474
|
expect(() => match([], { ok: () => {} })).toThrow(RequiredOwnerError)
|
|
254
475
|
})
|
package/test/signal.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createMutableSignal,
|
|
7
7
|
createScope,
|
|
8
8
|
createSignal,
|
|
9
|
+
createSlot,
|
|
9
10
|
createState,
|
|
10
11
|
createStore,
|
|
11
12
|
createTask,
|
|
@@ -229,6 +230,7 @@ describe('isSignal', () => {
|
|
|
229
230
|
expect(isSignal(createTask(async () => 42))).toBe(true)
|
|
230
231
|
expect(isSignal(createStore({ a: 1 }))).toBe(true)
|
|
231
232
|
expect(isSignal(createList([1, 2, 3]))).toBe(true)
|
|
233
|
+
expect(isSignal(createSlot(createState(1)))).toBe(true)
|
|
232
234
|
})
|
|
233
235
|
cleanup()
|
|
234
236
|
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
batch,
|
|
4
|
+
createEffect,
|
|
5
|
+
createMemo,
|
|
6
|
+
createSlot,
|
|
7
|
+
createState,
|
|
8
|
+
} from '../index.ts'
|
|
9
|
+
import { InvalidSignalValueError, NullishSignalValueError } from '../src/errors'
|
|
10
|
+
|
|
11
|
+
describe('Slot', () => {
|
|
12
|
+
test('should replace delegated signal and re-subscribe sinks', () => {
|
|
13
|
+
const local = createState(1)
|
|
14
|
+
const parent = createState(10)
|
|
15
|
+
const derived = createMemo(() => parent.get())
|
|
16
|
+
const slot = createSlot(local)
|
|
17
|
+
|
|
18
|
+
const target = {}
|
|
19
|
+
Object.defineProperty(target, 'value', slot)
|
|
20
|
+
|
|
21
|
+
let runs = 0
|
|
22
|
+
let seen = 0
|
|
23
|
+
createEffect(() => {
|
|
24
|
+
seen = (target as { value: number }).value
|
|
25
|
+
runs++
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(runs).toBe(1)
|
|
29
|
+
expect(seen).toBe(1)
|
|
30
|
+
|
|
31
|
+
slot.replace(derived)
|
|
32
|
+
expect(runs).toBe(2)
|
|
33
|
+
expect(seen).toBe(10)
|
|
34
|
+
|
|
35
|
+
// Old delegated signal should no longer trigger downstream sinks
|
|
36
|
+
local.set(2)
|
|
37
|
+
expect(runs).toBe(2)
|
|
38
|
+
|
|
39
|
+
parent.set(11)
|
|
40
|
+
expect(runs).toBe(3)
|
|
41
|
+
expect(seen).toBe(11)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('should forward property set to writable delegated signal', () => {
|
|
45
|
+
const source = createState(2)
|
|
46
|
+
const slot = createSlot(source)
|
|
47
|
+
const target = {}
|
|
48
|
+
Object.defineProperty(target, 'value', slot)
|
|
49
|
+
;(target as { value: number }).value = 3
|
|
50
|
+
|
|
51
|
+
expect(source.get()).toBe(3)
|
|
52
|
+
expect((target as { value: number }).value).toBe(3)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('should throw on set when delegated signal is read-only', () => {
|
|
56
|
+
const source = createState(2)
|
|
57
|
+
const readonly = createMemo(() => source.get() * 2)
|
|
58
|
+
const slot = createSlot(source)
|
|
59
|
+
const target = {}
|
|
60
|
+
Object.defineProperty(target, 'value', slot)
|
|
61
|
+
slot.replace(readonly)
|
|
62
|
+
|
|
63
|
+
expect(() => {
|
|
64
|
+
;(target as { value: number }).value = 7
|
|
65
|
+
}).toThrow('[Slot] Signal is read-only')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('should keep replace handle outside property descriptor', () => {
|
|
69
|
+
const source = createState(1)
|
|
70
|
+
const slot = createSlot(source)
|
|
71
|
+
const target = {}
|
|
72
|
+
Object.defineProperty(target, 'value', slot)
|
|
73
|
+
|
|
74
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, 'value')
|
|
75
|
+
expect(descriptor).toBeDefined()
|
|
76
|
+
expect(typeof descriptor?.get).toBe('function')
|
|
77
|
+
expect(typeof descriptor?.set).toBe('function')
|
|
78
|
+
expect((descriptor as unknown as { replace?: unknown }).replace).toBe(
|
|
79
|
+
undefined,
|
|
80
|
+
)
|
|
81
|
+
expect(typeof slot.replace).toBe('function')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('should batch multiple replacements into one downstream rerun', () => {
|
|
85
|
+
const a = createState(1)
|
|
86
|
+
const b = createState(2)
|
|
87
|
+
const c = createState(3)
|
|
88
|
+
const slot = createSlot(a)
|
|
89
|
+
const target = {}
|
|
90
|
+
Object.defineProperty(target, 'value', slot)
|
|
91
|
+
|
|
92
|
+
let runs = 0
|
|
93
|
+
createEffect(() => {
|
|
94
|
+
void (target as { value: number }).value
|
|
95
|
+
runs++
|
|
96
|
+
})
|
|
97
|
+
expect(runs).toBe(1)
|
|
98
|
+
|
|
99
|
+
batch(() => {
|
|
100
|
+
slot.replace(b)
|
|
101
|
+
slot.replace(c)
|
|
102
|
+
})
|
|
103
|
+
expect(runs).toBe(2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('should validate initial signal and replacement signal', () => {
|
|
107
|
+
expect(() => {
|
|
108
|
+
// @ts-expect-error: deliberate error test
|
|
109
|
+
createSlot(null)
|
|
110
|
+
}).toThrow(NullishSignalValueError)
|
|
111
|
+
|
|
112
|
+
const slot = createSlot(createState(1))
|
|
113
|
+
expect(() => {
|
|
114
|
+
// @ts-expect-error: deliberate error test
|
|
115
|
+
slot.replace(42)
|
|
116
|
+
}).toThrow(InvalidSignalValueError)
|
|
117
|
+
})
|
|
118
|
+
})
|
package/types/index.d.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @name Cause & Effect
|
|
3
|
-
* @version 0.18.
|
|
3
|
+
* @version 0.18.3
|
|
4
4
|
* @author Esther Brunner
|
|
5
5
|
*/
|
|
6
|
-
export { CircularDependencyError, type Guard, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors';
|
|
6
|
+
export { CircularDependencyError, type Guard, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, ReadonlySignalError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors';
|
|
7
7
|
export { batch, type Cleanup, type ComputedOptions, createScope, type EffectCallback, type MaybeCleanup, type MemoCallback, type Signal, type SignalOptions, SKIP_EQUALITY, type TaskCallback, untrack, } from './src/graph';
|
|
8
8
|
export { type Collection, type CollectionCallback, type CollectionChanges, type CollectionOptions, createCollection, type DeriveCollectionCallback, isCollection, } from './src/nodes/collection';
|
|
9
9
|
export { createEffect, type MatchHandlers, type MaybePromise, match, } from './src/nodes/effect';
|
|
10
10
|
export { createList, isEqual, isList, type KeyConfig, type List, type ListOptions, } from './src/nodes/list';
|
|
11
11
|
export { createMemo, isMemo, type Memo } from './src/nodes/memo';
|
|
12
12
|
export { createSensor, isSensor, type Sensor, type SensorCallback, type SensorOptions, } from './src/nodes/sensor';
|
|
13
|
+
export { createSlot, isSlot, type Slot } from './src/nodes/slot';
|
|
13
14
|
export { createState, isState, type State, type UpdateCallback, } from './src/nodes/state';
|
|
14
15
|
export { createStore, isStore, type Store, type StoreOptions, } from './src/nodes/store';
|
|
15
16
|
export { createTask, isTask, type Task } from './src/nodes/task';
|
package/types/src/errors.d.ts
CHANGED
|
@@ -64,6 +64,14 @@ declare class InvalidCallbackError extends TypeError {
|
|
|
64
64
|
*/
|
|
65
65
|
constructor(where: string, value: unknown);
|
|
66
66
|
}
|
|
67
|
+
declare class ReadonlySignalError extends Error {
|
|
68
|
+
/**
|
|
69
|
+
* Constructs a new ReadonlySignalError.
|
|
70
|
+
*
|
|
71
|
+
* @param where - The location where the error occurred.
|
|
72
|
+
*/
|
|
73
|
+
constructor(where: string);
|
|
74
|
+
}
|
|
67
75
|
/**
|
|
68
76
|
* Error thrown when an API requiring an owner is called without one.
|
|
69
77
|
*/
|
|
@@ -82,4 +90,4 @@ declare function validateSignalValue<T extends {}>(where: string, value: unknown
|
|
|
82
90
|
declare function validateReadValue<T extends {}>(where: string, value: T | null | undefined): asserts value is T;
|
|
83
91
|
declare function validateCallback(where: string, value: unknown): asserts value is (...args: unknown[]) => unknown;
|
|
84
92
|
declare function validateCallback<T>(where: string, value: unknown, guard: (value: unknown) => value is T): asserts value is T;
|
|
85
|
-
export { type Guard, CircularDependencyError, NullishSignalValueError, InvalidSignalValueError, UnsetSignalValueError, InvalidCallbackError, RequiredOwnerError, DuplicateKeyError, validateSignalValue, validateReadValue, validateCallback, };
|
|
93
|
+
export { type Guard, CircularDependencyError, NullishSignalValueError, InvalidSignalValueError, UnsetSignalValueError, InvalidCallbackError, ReadonlySignalError, RequiredOwnerError, DuplicateKeyError, validateSignalValue, validateReadValue, validateCallback, };
|
package/types/src/graph.d.ts
CHANGED
|
@@ -117,8 +117,11 @@ declare const TYPE_SENSOR = "Sensor";
|
|
|
117
117
|
declare const TYPE_LIST = "List";
|
|
118
118
|
declare const TYPE_COLLECTION = "Collection";
|
|
119
119
|
declare const TYPE_STORE = "Store";
|
|
120
|
+
declare const TYPE_SLOT = "Slot";
|
|
120
121
|
declare const FLAG_CLEAN = 0;
|
|
122
|
+
declare const FLAG_CHECK: number;
|
|
121
123
|
declare const FLAG_DIRTY: number;
|
|
124
|
+
declare const FLAG_RELINK: number;
|
|
122
125
|
declare let activeSink: SinkNode | null;
|
|
123
126
|
declare let activeOwner: OwnerNode | null;
|
|
124
127
|
declare let batchDepth: number;
|
|
@@ -215,4 +218,4 @@ declare function untrack<T>(fn: () => T): T;
|
|
|
215
218
|
* ```
|
|
216
219
|
*/
|
|
217
220
|
declare function createScope(fn: () => MaybeCleanup): Cleanup;
|
|
218
|
-
export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, SKIP_EQUALITY, FLAG_CLEAN, FLAG_DIRTY, flush, link, propagate, refresh, registerCleanup, runCleanup, runEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_STORE, TYPE_TASK, unlink, untrack, };
|
|
221
|
+
export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, SKIP_EQUALITY, FLAG_CHECK, FLAG_CLEAN, FLAG_DIRTY, FLAG_RELINK, flush, link, propagate, refresh, registerCleanup, runCleanup, runEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_SLOT, TYPE_STORE, TYPE_TASK, unlink, untrack, };
|