@zeix/cause-effect 0.18.1 → 0.18.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 +12 -4
- package/.github/copilot-instructions.md +10 -2
- package/ARCHITECTURE.md +44 -10
- package/CHANGELOG.md +24 -0
- package/CLAUDE.md +49 -1
- package/GUIDE.md +23 -1
- package/README.md +46 -1
- package/REQUIREMENTS.md +4 -3
- package/index.dev.js +170 -71
- 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 +17 -3
- package/src/nodes/collection.ts +43 -33
- package/src/nodes/effect.ts +3 -3
- package/src/nodes/list.ts +16 -12
- package/src/nodes/slot.ts +134 -0
- package/src/nodes/store.ts +16 -12
- package/src/signal.ts +2 -0
- package/test/effect.test.ts +17 -0
- package/test/list.test.ts +192 -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 +3 -1
- package/types/src/nodes/effect.d.ts +2 -2
- package/types/src/nodes/slot.d.ts +53 -0
package/src/nodes/collection.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type Cleanup,
|
|
10
10
|
FLAG_CLEAN,
|
|
11
11
|
FLAG_DIRTY,
|
|
12
|
+
FLAG_RELINK,
|
|
12
13
|
link,
|
|
13
14
|
type MemoNode,
|
|
14
15
|
propagate,
|
|
@@ -99,10 +100,6 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
99
100
|
callback: DeriveCollectionCallback<T, U>,
|
|
100
101
|
): Collection<T> {
|
|
101
102
|
validateCallback(TYPE_COLLECTION, callback)
|
|
102
|
-
if (!isCollectionSource(source))
|
|
103
|
-
throw new TypeError(
|
|
104
|
-
`[${TYPE_COLLECTION}] Invalid collection source: expected a List or Collection`,
|
|
105
|
-
)
|
|
106
103
|
|
|
107
104
|
const isAsync = isAsyncFunction(callback)
|
|
108
105
|
const signals = new Map<string, Memo<T>>()
|
|
@@ -129,13 +126,10 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
129
126
|
signals.set(key, signal as Memo<T>)
|
|
130
127
|
}
|
|
131
128
|
|
|
132
|
-
// Sync signals map with
|
|
133
|
-
// to establish a graph edge from source → this node.
|
|
129
|
+
// Sync signals map with the given keys.
|
|
134
130
|
// Intentionally side-effectful: mutates the private signals map and keys
|
|
135
|
-
// array.
|
|
136
|
-
function syncKeys(): void {
|
|
137
|
-
const nextKeys = Array.from(source.keys())
|
|
138
|
-
|
|
131
|
+
// array. Sets FLAG_RELINK on the node if keys changed.
|
|
132
|
+
function syncKeys(nextKeys: string[]): void {
|
|
139
133
|
if (!keysEqual(keys, nextKeys)) {
|
|
140
134
|
const a = new Set(keys)
|
|
141
135
|
const b = new Set(nextKeys)
|
|
@@ -143,19 +137,15 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
143
137
|
for (const key of keys) if (!b.has(key)) signals.delete(key)
|
|
144
138
|
for (const key of nextKeys) if (!a.has(key)) addSignal(key)
|
|
145
139
|
keys = nextKeys
|
|
146
|
-
|
|
147
|
-
// Force re-establishment of edges on next refresh() so new
|
|
148
|
-
// child signals are properly linked to this node
|
|
149
|
-
node.sources = null
|
|
150
|
-
node.sourcesTail = null
|
|
140
|
+
node.flags |= FLAG_RELINK
|
|
151
141
|
}
|
|
152
142
|
}
|
|
153
143
|
|
|
154
|
-
// Build current value from child signals
|
|
155
|
-
//
|
|
156
|
-
// to establish
|
|
144
|
+
// Build current value from child signals.
|
|
145
|
+
// Reads source.keys() to sync the signals map and — during refresh() —
|
|
146
|
+
// to establish a graph edge from source → this node.
|
|
157
147
|
function buildValue(): T[] {
|
|
158
|
-
syncKeys()
|
|
148
|
+
syncKeys(Array.from(source.keys()))
|
|
159
149
|
const result: T[] = []
|
|
160
150
|
for (const key of keys) {
|
|
161
151
|
try {
|
|
@@ -196,26 +186,40 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
196
186
|
if (node.sources) {
|
|
197
187
|
if (node.flags) {
|
|
198
188
|
node.value = untrack(buildValue)
|
|
199
|
-
node.flags
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
189
|
+
if (node.flags & FLAG_RELINK) {
|
|
190
|
+
// Keys changed — new child signals need graph edges.
|
|
191
|
+
// Tracked recompute so link() adds new edges and
|
|
192
|
+
// trimSources() removes stale ones without orphaning.
|
|
203
193
|
node.flags = FLAG_DIRTY
|
|
204
194
|
refresh(node as unknown as SinkNode)
|
|
205
195
|
if (node.error) throw node.error
|
|
196
|
+
} else {
|
|
197
|
+
node.flags = FLAG_CLEAN
|
|
206
198
|
}
|
|
207
199
|
}
|
|
208
|
-
} else {
|
|
200
|
+
} else if (node.sinks) {
|
|
201
|
+
// First access with a downstream subscriber — use refresh()
|
|
202
|
+
// to establish graph edges via recomputeMemo
|
|
209
203
|
refresh(node as unknown as SinkNode)
|
|
210
204
|
if (node.error) throw node.error
|
|
205
|
+
} else {
|
|
206
|
+
// No subscribers yet (e.g., chained deriveCollection init) —
|
|
207
|
+
// compute value without establishing graph edges to prevent
|
|
208
|
+
// premature watched activation on upstream sources.
|
|
209
|
+
// Keep FLAG_DIRTY so the first refresh() with a real subscriber
|
|
210
|
+
// will establish proper graph edges.
|
|
211
|
+
node.value = untrack(buildValue)
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
214
|
-
// Initialize signals for current source keys
|
|
215
|
-
|
|
215
|
+
// Initialize signals for current source keys — untrack to prevent
|
|
216
|
+
// triggering watched callbacks on upstream sources during construction.
|
|
217
|
+
// The first refresh() (triggered by an effect) will establish proper
|
|
218
|
+
// graph edges; this just populates the signals map for direct access.
|
|
219
|
+
const initialKeys = Array.from(untrack(() => source.keys()))
|
|
216
220
|
for (const key of initialKeys) addSignal(key)
|
|
217
221
|
keys = initialKeys
|
|
218
|
-
// Keep FLAG_DIRTY so the first refresh() establishes edges
|
|
222
|
+
// Keep FLAG_DIRTY so the first refresh() establishes edges.
|
|
219
223
|
|
|
220
224
|
const collection: Collection<T> = {
|
|
221
225
|
[Symbol.toStringTag]: TYPE_COLLECTION,
|
|
@@ -392,12 +396,8 @@ function createCollection<T extends {}>(
|
|
|
392
396
|
}
|
|
393
397
|
}
|
|
394
398
|
|
|
395
|
-
if (structural) {
|
|
396
|
-
node.sources = null
|
|
397
|
-
node.sourcesTail = null
|
|
398
|
-
}
|
|
399
399
|
// Mark DIRTY so next get() rebuilds; propagate to sinks
|
|
400
|
-
node.flags = FLAG_DIRTY
|
|
400
|
+
node.flags = FLAG_DIRTY | (structural ? FLAG_RELINK : 0)
|
|
401
401
|
for (let e = node.sinks; e; e = e.nextSink)
|
|
402
402
|
propagate(e.sink)
|
|
403
403
|
})
|
|
@@ -431,8 +431,18 @@ function createCollection<T extends {}>(
|
|
|
431
431
|
subscribe()
|
|
432
432
|
if (node.sources) {
|
|
433
433
|
if (node.flags) {
|
|
434
|
+
const relink = node.flags & FLAG_RELINK
|
|
434
435
|
node.value = untrack(buildValue)
|
|
435
|
-
|
|
436
|
+
if (relink) {
|
|
437
|
+
// Structural mutation added/removed child signals —
|
|
438
|
+
// tracked recompute so link() adds new edges and
|
|
439
|
+
// trimSources() removes stale ones without orphaning.
|
|
440
|
+
node.flags = FLAG_DIRTY
|
|
441
|
+
refresh(node as unknown as SinkNode)
|
|
442
|
+
if (node.error) throw node.error
|
|
443
|
+
} else {
|
|
444
|
+
node.flags = FLAG_CLEAN
|
|
445
|
+
}
|
|
436
446
|
}
|
|
437
447
|
} else {
|
|
438
448
|
refresh(node as unknown as SinkNode)
|
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/list.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type Cleanup,
|
|
11
11
|
FLAG_CLEAN,
|
|
12
12
|
FLAG_DIRTY,
|
|
13
|
+
FLAG_RELINK,
|
|
13
14
|
flush,
|
|
14
15
|
link,
|
|
15
16
|
type MemoNode,
|
|
@@ -263,7 +264,7 @@ function createList<T extends {}>(
|
|
|
263
264
|
// Structural tracking node — not a general-purpose Memo.
|
|
264
265
|
// On first get(): refresh() establishes edges from child signals.
|
|
265
266
|
// On subsequent get(): untrack(buildValue) rebuilds without re-linking.
|
|
266
|
-
// Mutation methods
|
|
267
|
+
// Mutation methods set FLAG_RELINK to force re-establishment on next read.
|
|
267
268
|
const node: MemoNode<T[]> = {
|
|
268
269
|
fn: buildValue,
|
|
269
270
|
value,
|
|
@@ -325,10 +326,7 @@ function createList<T extends {}>(
|
|
|
325
326
|
structural = true
|
|
326
327
|
}
|
|
327
328
|
|
|
328
|
-
if (structural)
|
|
329
|
-
node.sources = null
|
|
330
|
-
node.sourcesTail = null
|
|
331
|
-
}
|
|
329
|
+
if (structural) node.flags |= FLAG_RELINK
|
|
332
330
|
|
|
333
331
|
return changes.changed
|
|
334
332
|
}
|
|
@@ -380,8 +378,18 @@ function createList<T extends {}>(
|
|
|
380
378
|
if (node.sources) {
|
|
381
379
|
// Fast path: edges already established, rebuild value directly
|
|
382
380
|
if (node.flags) {
|
|
381
|
+
const relink = node.flags & FLAG_RELINK
|
|
383
382
|
node.value = untrack(buildValue)
|
|
384
|
-
|
|
383
|
+
if (relink) {
|
|
384
|
+
// Structural mutation added/removed child signals —
|
|
385
|
+
// tracked recompute so link() adds new edges and
|
|
386
|
+
// trimSources() removes stale ones without orphaning.
|
|
387
|
+
node.flags = FLAG_DIRTY
|
|
388
|
+
refresh(node as unknown as SinkNode)
|
|
389
|
+
if (node.error) throw node.error
|
|
390
|
+
} else {
|
|
391
|
+
node.flags = FLAG_CLEAN
|
|
392
|
+
}
|
|
385
393
|
}
|
|
386
394
|
} else {
|
|
387
395
|
// First access: use refresh() to establish child → list edges
|
|
@@ -441,9 +449,7 @@ function createList<T extends {}>(
|
|
|
441
449
|
if (!keys.includes(key)) keys.push(key)
|
|
442
450
|
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
|
|
443
451
|
signals.set(key, createState(value))
|
|
444
|
-
node.
|
|
445
|
-
node.sourcesTail = null
|
|
446
|
-
node.flags |= FLAG_DIRTY
|
|
452
|
+
node.flags |= FLAG_DIRTY | FLAG_RELINK
|
|
447
453
|
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
|
|
448
454
|
if (batchDepth === 0) flush()
|
|
449
455
|
return key
|
|
@@ -459,9 +465,7 @@ function createList<T extends {}>(
|
|
|
459
465
|
? keyOrIndex
|
|
460
466
|
: keys.indexOf(key)
|
|
461
467
|
if (index >= 0) keys.splice(index, 1)
|
|
462
|
-
node.
|
|
463
|
-
node.sourcesTail = null
|
|
464
|
-
node.flags |= FLAG_DIRTY
|
|
468
|
+
node.flags |= FLAG_DIRTY | FLAG_RELINK
|
|
465
469
|
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
|
|
466
470
|
if (batchDepth === 0) flush()
|
|
467
471
|
}
|
|
@@ -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/store.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type Cleanup,
|
|
7
7
|
FLAG_CLEAN,
|
|
8
8
|
FLAG_DIRTY,
|
|
9
|
+
FLAG_RELINK,
|
|
9
10
|
flush,
|
|
10
11
|
link,
|
|
11
12
|
type MemoNode,
|
|
@@ -168,7 +169,7 @@ function createStore<T extends UnknownRecord>(
|
|
|
168
169
|
// Structural tracking node — not a general-purpose Memo.
|
|
169
170
|
// On first get(): refresh() establishes edges from child signals.
|
|
170
171
|
// On subsequent get(): untrack(buildValue) rebuilds without re-linking.
|
|
171
|
-
// Mutation methods
|
|
172
|
+
// Mutation methods set FLAG_RELINK to force re-establishment on next read.
|
|
172
173
|
const node: MemoNode<T> = {
|
|
173
174
|
fn: buildValue,
|
|
174
175
|
value,
|
|
@@ -214,10 +215,7 @@ function createStore<T extends UnknownRecord>(
|
|
|
214
215
|
structural = true
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
if (structural)
|
|
218
|
-
node.sources = null
|
|
219
|
-
node.sourcesTail = null
|
|
220
|
-
}
|
|
218
|
+
if (structural) node.flags |= FLAG_RELINK
|
|
221
219
|
|
|
222
220
|
return changes.changed
|
|
223
221
|
}
|
|
@@ -280,8 +278,18 @@ function createStore<T extends UnknownRecord>(
|
|
|
280
278
|
// from child signals using untrack to avoid creating spurious
|
|
281
279
|
// edges to the current effect/memo consumer
|
|
282
280
|
if (node.flags) {
|
|
281
|
+
const relink = node.flags & FLAG_RELINK
|
|
283
282
|
node.value = untrack(buildValue)
|
|
284
|
-
|
|
283
|
+
if (relink) {
|
|
284
|
+
// Structural mutation added/removed child signals —
|
|
285
|
+
// tracked recompute so link() adds new edges and
|
|
286
|
+
// trimSources() removes stale ones without orphaning.
|
|
287
|
+
node.flags = FLAG_DIRTY
|
|
288
|
+
refresh(node as unknown as SinkNode)
|
|
289
|
+
if (node.error) throw node.error
|
|
290
|
+
} else {
|
|
291
|
+
node.flags = FLAG_CLEAN
|
|
292
|
+
}
|
|
285
293
|
}
|
|
286
294
|
} else {
|
|
287
295
|
// First access: use refresh() to establish child → store edges
|
|
@@ -311,9 +319,7 @@ function createStore<T extends UnknownRecord>(
|
|
|
311
319
|
if (signals.has(key))
|
|
312
320
|
throw new DuplicateKeyError(TYPE_STORE, key, value)
|
|
313
321
|
addSignal(key, value)
|
|
314
|
-
node.
|
|
315
|
-
node.sourcesTail = null
|
|
316
|
-
node.flags |= FLAG_DIRTY
|
|
322
|
+
node.flags |= FLAG_DIRTY | FLAG_RELINK
|
|
317
323
|
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
|
|
318
324
|
if (batchDepth === 0) flush()
|
|
319
325
|
return key
|
|
@@ -322,9 +328,7 @@ function createStore<T extends UnknownRecord>(
|
|
|
322
328
|
remove(key: string) {
|
|
323
329
|
const ok = signals.delete(key)
|
|
324
330
|
if (ok) {
|
|
325
|
-
node.
|
|
326
|
-
node.sourcesTail = null
|
|
327
|
-
node.flags |= FLAG_DIRTY
|
|
331
|
+
node.flags |= FLAG_DIRTY | FLAG_RELINK
|
|
328
332
|
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
|
|
329
333
|
if (batchDepth === 0) flush()
|
|
330
334
|
}
|
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
|
@@ -249,6 +249,23 @@ describe('match', () => {
|
|
|
249
249
|
}
|
|
250
250
|
})
|
|
251
251
|
|
|
252
|
+
test('should preserve tuple types in ok handler', () => {
|
|
253
|
+
const a = createState(1)
|
|
254
|
+
const b = createState('hello')
|
|
255
|
+
createEffect(() =>
|
|
256
|
+
match([a, b], {
|
|
257
|
+
ok: ([aVal, bVal]) => {
|
|
258
|
+
// If tuple types are preserved, aVal is number and bVal is string
|
|
259
|
+
// If widened, both would be string | number
|
|
260
|
+
const num: number = aVal
|
|
261
|
+
const str: string = bVal
|
|
262
|
+
expect(num).toBe(1)
|
|
263
|
+
expect(str).toBe('hello')
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
)
|
|
267
|
+
})
|
|
268
|
+
|
|
252
269
|
test('should throw RequiredOwnerError when called outside an owner', () => {
|
|
253
270
|
expect(() => match([], { ok: () => {} })).toThrow(RequiredOwnerError)
|
|
254
271
|
})
|
package/test/list.test.ts
CHANGED
|
@@ -3,10 +3,18 @@ import {
|
|
|
3
3
|
createEffect,
|
|
4
4
|
createList,
|
|
5
5
|
createMemo,
|
|
6
|
+
createScope,
|
|
7
|
+
createState,
|
|
8
|
+
createTask,
|
|
6
9
|
isList,
|
|
7
10
|
isMemo,
|
|
11
|
+
match,
|
|
8
12
|
} from '../index.ts'
|
|
9
13
|
|
|
14
|
+
/* === Utility Functions === */
|
|
15
|
+
|
|
16
|
+
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
17
|
+
|
|
10
18
|
describe('List', () => {
|
|
11
19
|
describe('createList', () => {
|
|
12
20
|
test('should return initial values from get()', () => {
|
|
@@ -437,6 +445,190 @@ describe('List', () => {
|
|
|
437
445
|
expect(watchedCalled).toBe(true)
|
|
438
446
|
dispose()
|
|
439
447
|
})
|
|
448
|
+
|
|
449
|
+
test('should activate watched via sync deriveCollection', () => {
|
|
450
|
+
let watchedCalled = false
|
|
451
|
+
let unwatchedCalled = false
|
|
452
|
+
const list = createList([1, 2, 3], {
|
|
453
|
+
watched: () => {
|
|
454
|
+
watchedCalled = true
|
|
455
|
+
return () => {
|
|
456
|
+
unwatchedCalled = true
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const derived = list.deriveCollection((v: number) => v * 2)
|
|
462
|
+
|
|
463
|
+
expect(watchedCalled).toBe(false)
|
|
464
|
+
|
|
465
|
+
const dispose = createEffect(() => {
|
|
466
|
+
derived.get()
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
expect(watchedCalled).toBe(true)
|
|
470
|
+
expect(unwatchedCalled).toBe(false)
|
|
471
|
+
|
|
472
|
+
dispose()
|
|
473
|
+
expect(unwatchedCalled).toBe(true)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
test('should activate watched via async deriveCollection', async () => {
|
|
477
|
+
let watchedCalled = false
|
|
478
|
+
let unwatchedCalled = false
|
|
479
|
+
const list = createList([1, 2, 3], {
|
|
480
|
+
watched: () => {
|
|
481
|
+
watchedCalled = true
|
|
482
|
+
return () => {
|
|
483
|
+
unwatchedCalled = true
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
const derived = list.deriveCollection(
|
|
489
|
+
async (v: number, _abort: AbortSignal) => v * 2,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
expect(watchedCalled).toBe(false)
|
|
493
|
+
|
|
494
|
+
const dispose = createEffect(() => {
|
|
495
|
+
derived.get()
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
expect(watchedCalled).toBe(true)
|
|
499
|
+
|
|
500
|
+
await wait(10)
|
|
501
|
+
|
|
502
|
+
expect(unwatchedCalled).toBe(false)
|
|
503
|
+
|
|
504
|
+
dispose()
|
|
505
|
+
expect(unwatchedCalled).toBe(true)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test('should not tear down watched during list mutation via deriveCollection', () => {
|
|
509
|
+
let activations = 0
|
|
510
|
+
let deactivations = 0
|
|
511
|
+
const list = createList([1, 2], {
|
|
512
|
+
watched: () => {
|
|
513
|
+
activations++
|
|
514
|
+
return () => {
|
|
515
|
+
deactivations++
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
const derived = list.deriveCollection((v: number) => v * 2)
|
|
521
|
+
|
|
522
|
+
let result: number[] = []
|
|
523
|
+
const dispose = createEffect(() => {
|
|
524
|
+
result = derived.get()
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
expect(activations).toBe(1)
|
|
528
|
+
expect(deactivations).toBe(0)
|
|
529
|
+
expect(result).toEqual([2, 4])
|
|
530
|
+
|
|
531
|
+
// Add item — should NOT tear down and restart watched
|
|
532
|
+
list.add(3)
|
|
533
|
+
expect(result).toEqual([2, 4, 6])
|
|
534
|
+
expect(activations).toBe(1)
|
|
535
|
+
expect(deactivations).toBe(0)
|
|
536
|
+
|
|
537
|
+
// Remove item — should NOT tear down and restart watched
|
|
538
|
+
list.remove(0)
|
|
539
|
+
expect(activations).toBe(1)
|
|
540
|
+
expect(deactivations).toBe(0)
|
|
541
|
+
|
|
542
|
+
dispose()
|
|
543
|
+
expect(deactivations).toBe(1)
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
test('should delay watched activation for conditional reads', () => {
|
|
547
|
+
let watchedCalled = false
|
|
548
|
+
const list = createList([1, 2], {
|
|
549
|
+
watched: () => {
|
|
550
|
+
watchedCalled = true
|
|
551
|
+
return () => {}
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
const show = createState(false)
|
|
556
|
+
|
|
557
|
+
const dispose = createScope(() => {
|
|
558
|
+
createEffect(() => {
|
|
559
|
+
if (show.get()) {
|
|
560
|
+
list.get()
|
|
561
|
+
}
|
|
562
|
+
})
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
// Conditional read — list not accessed, watched should not fire
|
|
566
|
+
expect(watchedCalled).toBe(false)
|
|
567
|
+
|
|
568
|
+
// Flip condition — list is now accessed
|
|
569
|
+
show.set(true)
|
|
570
|
+
expect(watchedCalled).toBe(true)
|
|
571
|
+
|
|
572
|
+
dispose()
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
test('should activate watched via chained deriveCollection', () => {
|
|
576
|
+
let watchedCalled = false
|
|
577
|
+
const list = createList([1, 2, 3], {
|
|
578
|
+
watched: () => {
|
|
579
|
+
watchedCalled = true
|
|
580
|
+
return () => {}
|
|
581
|
+
},
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
const doubled = list.deriveCollection((v: number) => v * 2)
|
|
585
|
+
const quadrupled = doubled.deriveCollection((v: number) => v * 2)
|
|
586
|
+
|
|
587
|
+
expect(watchedCalled).toBe(false)
|
|
588
|
+
|
|
589
|
+
const dispose = createEffect(() => {
|
|
590
|
+
quadrupled.get()
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
expect(watchedCalled).toBe(true)
|
|
594
|
+
dispose()
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('should activate watched via deriveCollection read inside match()', async () => {
|
|
598
|
+
let watchedCalled = false
|
|
599
|
+
const list = createList([1, 2], {
|
|
600
|
+
watched: () => {
|
|
601
|
+
watchedCalled = true
|
|
602
|
+
return () => {}
|
|
603
|
+
},
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
const derived = list.deriveCollection((v: number) => v * 10)
|
|
607
|
+
|
|
608
|
+
const task = createTask(async () => {
|
|
609
|
+
await wait(10)
|
|
610
|
+
return 'done'
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
const dispose = createScope(() => {
|
|
614
|
+
createEffect(() => {
|
|
615
|
+
// Read derived BEFORE match to ensure subscription
|
|
616
|
+
const values = derived.get()
|
|
617
|
+
match([task], {
|
|
618
|
+
ok: () => {
|
|
619
|
+
void values
|
|
620
|
+
},
|
|
621
|
+
nil: () => {},
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
// watched should activate synchronously even though task is pending
|
|
627
|
+
expect(watchedCalled).toBe(true)
|
|
628
|
+
|
|
629
|
+
await wait(50)
|
|
630
|
+
dispose()
|
|
631
|
+
})
|
|
440
632
|
})
|
|
441
633
|
|
|
442
634
|
describe('Input Validation', () => {
|