@zeix/cause-effect 0.18.2 → 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.
@@ -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/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
  })
@@ -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.1
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';
@@ -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, };
@@ -117,8 +117,10 @@ 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;
121
122
  declare const FLAG_DIRTY: number;
123
+ declare const FLAG_RELINK: number;
122
124
  declare let activeSink: SinkNode | null;
123
125
  declare let activeOwner: OwnerNode | null;
124
126
  declare let batchDepth: number;
@@ -215,4 +217,4 @@ declare function untrack<T>(fn: () => T): T;
215
217
  * ```
216
218
  */
217
219
  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, };
220
+ 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, 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, };
@@ -1,6 +1,6 @@
1
1
  import { type Cleanup, type EffectCallback, type MaybeCleanup, type Signal } from '../graph';
2
2
  type MaybePromise<T> = T | Promise<T>;
3
- type MatchHandlers<T extends Signal<unknown & {}>[]> = {
3
+ type MatchHandlers<T extends readonly Signal<unknown & {}>[]> = {
4
4
  ok: (values: {
5
5
  [K in keyof T]: T[K] extends Signal<infer V> ? V : never;
6
6
  }) => MaybePromise<MaybeCleanup>;
@@ -44,5 +44,5 @@ declare function createEffect(fn: EffectCallback): Cleanup;
44
44
  * @since 0.15.0
45
45
  * @throws RequiredOwnerError If called without an active owner.
46
46
  */
47
- declare function match<T extends Signal<unknown & {}>[]>(signals: T, handlers: MatchHandlers<T>): MaybeCleanup;
47
+ declare function match<T extends readonly Signal<unknown & {}>[]>(signals: readonly [...T], handlers: MatchHandlers<T>): MaybeCleanup;
48
48
  export { type MaybePromise, type MatchHandlers, createEffect, match };
@@ -0,0 +1,53 @@
1
+ import { type Signal, type SignalOptions } from '../graph';
2
+ /**
3
+ * A signal that delegates its value to a swappable backing signal.
4
+ *
5
+ * Slots provide a stable reactive source at a fixed position (e.g. an object property)
6
+ * while allowing the backing signal to be replaced without breaking subscribers.
7
+ * The object shape is compatible with `Object.defineProperty()` descriptors:
8
+ * `get`, `set`, `configurable`, and `enumerable` are used by the property definition;
9
+ * `replace()` and `current()` are kept on the slot object for integration-layer control.
10
+ *
11
+ * @template T - The type of value held by the delegated signal.
12
+ */
13
+ type Slot<T extends {}> = {
14
+ readonly [Symbol.toStringTag]: 'Slot';
15
+ /** Descriptor field: allows the property to be redefined or deleted. */
16
+ configurable: true;
17
+ /** Descriptor field: the property shows up during enumeration. */
18
+ enumerable: true;
19
+ /** Reads the current value from the delegated signal, tracking dependencies. */
20
+ get(): T;
21
+ /** Writes a value to the delegated signal. Throws `ReadonlySignalError` if the delegated signal is read-only. */
22
+ set(next: T): void;
23
+ /** Swaps the backing signal, invalidating all downstream subscribers. Narrowing (`U extends T`) is allowed. */
24
+ replace<U extends T>(next: Signal<U>): void;
25
+ /** Returns the currently delegated signal. */
26
+ current(): Signal<T>;
27
+ };
28
+ /**
29
+ * Creates a slot signal that delegates its value to a swappable backing signal.
30
+ *
31
+ * A slot acts as a stable reactive source that can be used as a property descriptor
32
+ * via `Object.defineProperty(target, key, slot)`. Subscribers link to the slot itself,
33
+ * so replacing the backing signal with `replace()` invalidates them without breaking
34
+ * existing edges. Setter calls forward to the current backing signal when it is writable.
35
+ *
36
+ * @since 0.18.3
37
+ * @template T - The type of value held by the delegated signal.
38
+ * @param initialSignal - The initial signal to delegate to.
39
+ * @param options - Optional configuration for the slot.
40
+ * @param options.equals - Custom equality function. Defaults to strict equality (`===`).
41
+ * @param options.guard - Type guard to validate values passed to `set()`.
42
+ * @returns A `Slot<T>` object usable both as a property descriptor and as a reactive signal.
43
+ */
44
+ declare function createSlot<T extends {}>(initialSignal: Signal<T>, options?: SignalOptions<T>): Slot<T>;
45
+ /**
46
+ * Checks if a value is a Slot signal.
47
+ *
48
+ * @since 0.18.3
49
+ * @param value - The value to check
50
+ * @returns True if the value is a Slot
51
+ */
52
+ declare function isSlot<T extends {} = unknown & {}>(value: unknown): value is Slot<T>;
53
+ export { createSlot, isSlot, type Slot };