@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.
@@ -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 source keys, reading source.keys()
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. The side effects are idempotent and scoped to private state.
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; syncKeys runs first to
155
- // ensure the signals map is up to date and — during refresh() —
156
- // to establish the graph edge from source → this node.
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 = FLAG_CLEAN
200
- // syncKeys may have nulled sources if keys changed
201
- // re-run with refresh() to establish edges to new child signals
202
- if (!node.sources) {
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
- const initialKeys = Array.from(source.keys())
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
- node.flags = FLAG_CLEAN
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)
@@ -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 (add/remove/set/splice) null out sources to force re-establishment.
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
- node.flags = FLAG_CLEAN
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.sources = null
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.sources = null
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 }
@@ -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 (add/remove/set) null out sources to force re-establishment.
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
- node.flags = FLAG_CLEAN
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.sources = null
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.sources = null
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,
@@ -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', () => {