@zeix/cause-effect 0.17.3 → 0.18.0
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 +163 -232
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +199 -143
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +232 -197
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1325 -997
- package/index.js +1 -1
- package/index.ts +58 -74
- package/package.json +4 -1
- package/src/errors.ts +118 -74
- package/src/graph.ts +601 -0
- package/src/nodes/collection.ts +474 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +588 -0
- package/src/nodes/memo.ts +120 -0
- package/src/nodes/sensor.ts +139 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +383 -0
- package/src/nodes/task.ts +146 -0
- package/src/signal.ts +112 -66
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -62
- package/test/benchmark.test.ts +473 -487
- package/test/collection.test.ts +466 -706
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- package/test/memo.test.ts +380 -0
- package/test/regression.test.ts +156 -0
- package/test/scope.test.ts +191 -0
- package/test/sensor.test.ts +454 -0
- package/test/signal.test.ts +220 -213
- package/test/state.test.ts +217 -265
- package/test/store.test.ts +346 -446
- package/test/task.test.ts +395 -0
- package/test/untrack.test.ts +167 -0
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -17
- package/types/src/graph.d.ts +208 -0
- package/types/src/nodes/collection.d.ts +64 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +65 -0
- package/types/src/nodes/memo.d.ts +57 -0
- package/types/src/nodes/sensor.d.ts +75 -0
- package/types/src/nodes/state.d.ts +78 -0
- package/types/src/nodes/store.d.ts +51 -0
- package/types/src/nodes/task.d.ts +73 -0
- package/types/src/signal.d.ts +43 -29
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -683
- package/archive/collection.ts +0 -253
- package/archive/composite.ts +0 -85
- package/archive/computed.ts +0 -195
- package/archive/list.ts +0 -483
- package/archive/memo.ts +0 -139
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -298
- package/archive/task.ts +0 -189
- package/src/classes/collection.ts +0 -245
- package/src/classes/computed.ts +0 -349
- package/src/classes/list.ts +0 -343
- package/src/classes/ref.ts +0 -70
- package/src/classes/state.ts +0 -102
- package/src/classes/store.ts +0 -262
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -93
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -257
- package/test/computed.test.ts +0 -1108
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -353
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -45
- package/types/src/classes/computed.d.ts +0 -94
- package/types/src/classes/list.d.ts +0 -43
- package/types/src/classes/ref.d.ts +0 -35
- package/types/src/classes/state.d.ts +0 -49
- package/types/src/classes/store.d.ts +0 -52
- package/types/src/diff.d.ts +0 -28
- package/types/src/effect.d.ts +0 -15
- package/types/src/match.d.ts +0 -21
- package/types/src/resolve.d.ts +0 -29
- package/types/src/system.d.ts +0 -78
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validateCallback,
|
|
3
|
+
validateReadValue,
|
|
4
|
+
validateSignalValue,
|
|
5
|
+
} from '../errors'
|
|
6
|
+
import {
|
|
7
|
+
activeSink,
|
|
8
|
+
type Cleanup,
|
|
9
|
+
type ComputedOptions,
|
|
10
|
+
defaultEquals,
|
|
11
|
+
link,
|
|
12
|
+
type StateNode,
|
|
13
|
+
setState,
|
|
14
|
+
TYPE_SENSOR,
|
|
15
|
+
} from '../graph'
|
|
16
|
+
import { isObjectOfType, isSyncFunction } from '../util'
|
|
17
|
+
|
|
18
|
+
/* === Types === */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A read-only signal that tracks external input and updates a state value as long as it is active.
|
|
22
|
+
*
|
|
23
|
+
* @template T - The type of value produced by the sensor
|
|
24
|
+
*/
|
|
25
|
+
type Sensor<T extends {}> = {
|
|
26
|
+
readonly [Symbol.toStringTag]: 'Sensor'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Gets the current value of the sensor.
|
|
30
|
+
* Updates its state value if the sensor is active.
|
|
31
|
+
* When called inside another reactive context, creates a dependency.
|
|
32
|
+
* @returns The sensor value
|
|
33
|
+
* @throws UnsetSignalValueError If the sensor value is still unset when read.
|
|
34
|
+
*/
|
|
35
|
+
get(): T
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A callback function for sensors when the sensor starts being watched.
|
|
40
|
+
*
|
|
41
|
+
* @template T - The type of value observed
|
|
42
|
+
* @param set - A function to set the observed value
|
|
43
|
+
* @returns A cleanup function when the sensor stops being watched
|
|
44
|
+
*/
|
|
45
|
+
type SensorCallback<T extends {}> = (set: (next: T) => void) => Cleanup
|
|
46
|
+
|
|
47
|
+
/* === Exported Functions === */
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates a sensor that tracks external input and updates a state value as long as it is active.
|
|
51
|
+
* Sensors get activated when they are first accessed by an effect and deactivated when they are
|
|
52
|
+
* no longer watched. This lazy activation pattern ensures resources are only consumed when needed.
|
|
53
|
+
*
|
|
54
|
+
* @since 0.18.0
|
|
55
|
+
* @template T - The type of value stored in the state
|
|
56
|
+
* @param start - The callback function that starts the sensor and returns a cleanup function.
|
|
57
|
+
* @param options - Optional options for the sensor.
|
|
58
|
+
* @param options.value - Optional initial value. Avoids `UnsetSignalValueError` on first read
|
|
59
|
+
* before the start callback fires. Essential for the mutable-object observation pattern.
|
|
60
|
+
* @param options.equals - Optional equality function. Defaults to `Object.is`. Use `SKIP_EQUALITY`
|
|
61
|
+
* for mutable objects where the reference stays the same but internal state changes.
|
|
62
|
+
* @param options.guard - Optional type guard to validate values.
|
|
63
|
+
* @returns A read-only sensor signal.
|
|
64
|
+
*
|
|
65
|
+
* @example Tracking external values
|
|
66
|
+
* ```ts
|
|
67
|
+
* const mousePos = createSensor<{ x: number; y: number }>((set) => {
|
|
68
|
+
* const handler = (e: MouseEvent) => {
|
|
69
|
+
* set({ x: e.clientX, y: e.clientY });
|
|
70
|
+
* };
|
|
71
|
+
* window.addEventListener('mousemove', handler);
|
|
72
|
+
* return () => window.removeEventListener('mousemove', handler);
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @example Observing a mutable object
|
|
77
|
+
* ```ts
|
|
78
|
+
* import { createSensor, SKIP_EQUALITY } from 'cause-effect';
|
|
79
|
+
*
|
|
80
|
+
* const el = createSensor<HTMLElement>((set) => {
|
|
81
|
+
* const node = document.getElementById('box')!;
|
|
82
|
+
* set(node);
|
|
83
|
+
* const obs = new MutationObserver(() => set(node));
|
|
84
|
+
* obs.observe(node, { attributes: true });
|
|
85
|
+
* return () => obs.disconnect();
|
|
86
|
+
* }, { value: node, equals: SKIP_EQUALITY });
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
function createSensor<T extends {}>(
|
|
90
|
+
start: SensorCallback<T>,
|
|
91
|
+
options?: ComputedOptions<T>,
|
|
92
|
+
): Sensor<T> {
|
|
93
|
+
validateCallback(TYPE_SENSOR, start, isSyncFunction)
|
|
94
|
+
if (options?.value !== undefined)
|
|
95
|
+
validateSignalValue(TYPE_SENSOR, options.value, options?.guard)
|
|
96
|
+
|
|
97
|
+
const node: StateNode<T> = {
|
|
98
|
+
value: options?.value as T,
|
|
99
|
+
sinks: null,
|
|
100
|
+
sinksTail: null,
|
|
101
|
+
equals: options?.equals ?? defaultEquals,
|
|
102
|
+
guard: options?.guard,
|
|
103
|
+
stop: undefined,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
[Symbol.toStringTag]: TYPE_SENSOR,
|
|
108
|
+
get(): T {
|
|
109
|
+
if (activeSink) {
|
|
110
|
+
// Start fires before link: synchronous set() inside start updates
|
|
111
|
+
// node.value without propagation (no sinks yet). The activating
|
|
112
|
+
// effect reads the updated value directly after link.
|
|
113
|
+
if (!node.sinks)
|
|
114
|
+
node.stop = start((next: T): void => {
|
|
115
|
+
validateSignalValue(TYPE_SENSOR, next, node.guard)
|
|
116
|
+
setState(node, next)
|
|
117
|
+
})
|
|
118
|
+
link(node, activeSink)
|
|
119
|
+
}
|
|
120
|
+
validateReadValue(TYPE_SENSOR, node.value)
|
|
121
|
+
return node.value
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Checks if a value is a Sensor signal.
|
|
128
|
+
*
|
|
129
|
+
* @since 0.18.0
|
|
130
|
+
* @param value - The value to check
|
|
131
|
+
* @returns True if the value is a Sensor
|
|
132
|
+
*/
|
|
133
|
+
function isSensor<T extends {} = unknown & {}>(
|
|
134
|
+
value: unknown,
|
|
135
|
+
): value is Sensor<T> {
|
|
136
|
+
return isObjectOfType(value, TYPE_SENSOR)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { createSensor, isSensor, type Sensor, type SensorCallback }
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { validateCallback, validateSignalValue } from '../errors'
|
|
2
|
+
import {
|
|
3
|
+
activeSink,
|
|
4
|
+
defaultEquals,
|
|
5
|
+
link,
|
|
6
|
+
type SignalOptions,
|
|
7
|
+
type StateNode,
|
|
8
|
+
setState,
|
|
9
|
+
TYPE_STATE,
|
|
10
|
+
} from '../graph'
|
|
11
|
+
import { isObjectOfType } from '../util'
|
|
12
|
+
|
|
13
|
+
/* === Types === */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A callback function for states that updates a value based on the previous value.
|
|
17
|
+
*
|
|
18
|
+
* @template T - The type of value
|
|
19
|
+
* @param prev - The previous state value
|
|
20
|
+
* @returns The new state value
|
|
21
|
+
*/
|
|
22
|
+
type UpdateCallback<T extends {}> = (prev: T) => T
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A mutable reactive state container.
|
|
26
|
+
* Changes to the state will automatically propagate to dependent computations and effects.
|
|
27
|
+
*
|
|
28
|
+
* @template T - The type of value stored in the state
|
|
29
|
+
*/
|
|
30
|
+
type State<T extends {}> = {
|
|
31
|
+
readonly [Symbol.toStringTag]: 'State'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Gets the current value of the state.
|
|
35
|
+
* When called inside a memo, task, or effect, creates a dependency.
|
|
36
|
+
* @returns The current value
|
|
37
|
+
*/
|
|
38
|
+
get(): T
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sets a new value for the state.
|
|
42
|
+
* If the new value is different (according to the equality function), all dependents will be notified.
|
|
43
|
+
* @param next - The new value to set
|
|
44
|
+
*/
|
|
45
|
+
set(next: T): void
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Updates the state with a new value computed by a callback function.
|
|
49
|
+
* The callback receives the current value as an argument.
|
|
50
|
+
* @param fn - The callback function to compute the new value
|
|
51
|
+
*/
|
|
52
|
+
update(fn: UpdateCallback<T>): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* === Exported Functions === */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Creates a mutable reactive state container.
|
|
59
|
+
*
|
|
60
|
+
* @since 0.9.0
|
|
61
|
+
* @template T - The type of value stored in the state
|
|
62
|
+
* @param value - The initial value
|
|
63
|
+
* @param options - Optional configuration for the state
|
|
64
|
+
* @returns A State object with get() and set() methods
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* const count = createState(0);
|
|
69
|
+
* count.set(1);
|
|
70
|
+
* console.log(count.get()); // 1
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* // With type guard
|
|
76
|
+
* const count = createState(0, {
|
|
77
|
+
* guard: (v): v is number => typeof v === 'number'
|
|
78
|
+
* });
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
function createState<T extends {}>(
|
|
82
|
+
value: T,
|
|
83
|
+
options?: SignalOptions<T>,
|
|
84
|
+
): State<T> {
|
|
85
|
+
validateSignalValue(TYPE_STATE, value, options?.guard)
|
|
86
|
+
|
|
87
|
+
const node: StateNode<T> = {
|
|
88
|
+
value,
|
|
89
|
+
sinks: null,
|
|
90
|
+
sinksTail: null,
|
|
91
|
+
equals: options?.equals ?? defaultEquals,
|
|
92
|
+
guard: options?.guard,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
[Symbol.toStringTag]: TYPE_STATE,
|
|
97
|
+
get(): T {
|
|
98
|
+
if (activeSink) link(node, activeSink)
|
|
99
|
+
return node.value
|
|
100
|
+
},
|
|
101
|
+
set(next: T): void {
|
|
102
|
+
validateSignalValue(TYPE_STATE, next, node.guard)
|
|
103
|
+
setState(node, next)
|
|
104
|
+
},
|
|
105
|
+
update(fn: UpdateCallback<T>): void {
|
|
106
|
+
validateCallback(TYPE_STATE, fn)
|
|
107
|
+
const next = fn(node.value)
|
|
108
|
+
validateSignalValue(TYPE_STATE, next, node.guard)
|
|
109
|
+
setState(node, next)
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Checks if a value is a State signal.
|
|
116
|
+
*
|
|
117
|
+
* @since 0.9.0
|
|
118
|
+
* @param value - The value to check
|
|
119
|
+
* @returns True if the value is a State
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* const state = createState(0);
|
|
124
|
+
* if (isState(state)) {
|
|
125
|
+
* state.set(1); // TypeScript knows state has set()
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
function isState<T extends {} = unknown & {}>(
|
|
130
|
+
value: unknown,
|
|
131
|
+
): value is State<T> {
|
|
132
|
+
return isObjectOfType(value, TYPE_STATE)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export { createState, isState, type State, type UpdateCallback }
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { DuplicateKeyError, validateSignalValue } from '../errors'
|
|
2
|
+
import {
|
|
3
|
+
activeSink,
|
|
4
|
+
batch,
|
|
5
|
+
batchDepth,
|
|
6
|
+
type Cleanup,
|
|
7
|
+
FLAG_CLEAN,
|
|
8
|
+
FLAG_DIRTY,
|
|
9
|
+
flush,
|
|
10
|
+
link,
|
|
11
|
+
type MemoNode,
|
|
12
|
+
propagate,
|
|
13
|
+
refresh,
|
|
14
|
+
type SinkNode,
|
|
15
|
+
TYPE_STORE,
|
|
16
|
+
untrack,
|
|
17
|
+
} from '../graph'
|
|
18
|
+
import { isFunction, isObjectOfType, isRecord } from '../util'
|
|
19
|
+
import {
|
|
20
|
+
createList,
|
|
21
|
+
type DiffResult,
|
|
22
|
+
isEqual,
|
|
23
|
+
type List,
|
|
24
|
+
type UnknownRecord,
|
|
25
|
+
} from './list'
|
|
26
|
+
import { createState, type State } from './state'
|
|
27
|
+
|
|
28
|
+
/* === Types === */
|
|
29
|
+
|
|
30
|
+
type StoreOptions = {
|
|
31
|
+
watched?: () => Cleanup
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type BaseStore<T extends UnknownRecord> = {
|
|
35
|
+
readonly [Symbol.toStringTag]: 'Store'
|
|
36
|
+
readonly [Symbol.isConcatSpreadable]: false
|
|
37
|
+
[Symbol.iterator](): IterableIterator<
|
|
38
|
+
[
|
|
39
|
+
string,
|
|
40
|
+
State<T[keyof T] & {}> | Store<UnknownRecord> | List<unknown & {}>,
|
|
41
|
+
]
|
|
42
|
+
>
|
|
43
|
+
keys(): IterableIterator<string>
|
|
44
|
+
byKey<K extends keyof T & string>(
|
|
45
|
+
key: K,
|
|
46
|
+
): T[K] extends readonly (infer U extends {})[]
|
|
47
|
+
? List<U>
|
|
48
|
+
: T[K] extends UnknownRecord
|
|
49
|
+
? Store<T[K]>
|
|
50
|
+
: T[K] extends unknown & {}
|
|
51
|
+
? State<T[K] & {}>
|
|
52
|
+
: State<T[K] & {}> | undefined
|
|
53
|
+
get(): T
|
|
54
|
+
set(newValue: T): void
|
|
55
|
+
update(fn: (oldValue: T) => T): void
|
|
56
|
+
add<K extends keyof T & string>(key: K, value: T[K]): K
|
|
57
|
+
remove(key: string): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Store<T extends UnknownRecord> = BaseStore<T> & {
|
|
61
|
+
[K in keyof T]: T[K] extends readonly (infer U extends {})[]
|
|
62
|
+
? List<U>
|
|
63
|
+
: T[K] extends UnknownRecord
|
|
64
|
+
? Store<T[K]>
|
|
65
|
+
: T[K] extends unknown & {}
|
|
66
|
+
? State<T[K] & {}>
|
|
67
|
+
: State<T[K] & {}> | undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* === Functions === */
|
|
71
|
+
|
|
72
|
+
/** Diff two records and return granular changes */
|
|
73
|
+
function diffRecords<T extends UnknownRecord>(
|
|
74
|
+
oldObj: T,
|
|
75
|
+
newObj: T,
|
|
76
|
+
): DiffResult {
|
|
77
|
+
// Guard against non-objects that can't be diffed properly with Object.keys and 'in' operator
|
|
78
|
+
const oldValid = isRecord(oldObj) || Array.isArray(oldObj)
|
|
79
|
+
const newValid = isRecord(newObj) || Array.isArray(newObj)
|
|
80
|
+
if (!oldValid || !newValid) {
|
|
81
|
+
// For non-objects or non-plain objects, treat as complete change if different
|
|
82
|
+
const changed = !Object.is(oldObj, newObj)
|
|
83
|
+
return {
|
|
84
|
+
changed,
|
|
85
|
+
add: changed && newValid ? newObj : {},
|
|
86
|
+
change: {},
|
|
87
|
+
remove: changed && oldValid ? oldObj : {},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const visited = new WeakSet()
|
|
92
|
+
|
|
93
|
+
const add = {} as UnknownRecord
|
|
94
|
+
const change = {} as UnknownRecord
|
|
95
|
+
const remove = {} as UnknownRecord
|
|
96
|
+
let changed = false
|
|
97
|
+
|
|
98
|
+
const oldKeys = Object.keys(oldObj)
|
|
99
|
+
const newKeys = Object.keys(newObj)
|
|
100
|
+
|
|
101
|
+
// Pass 1: iterate new keys — find additions and changes
|
|
102
|
+
for (const key of newKeys) {
|
|
103
|
+
if (key in oldObj) {
|
|
104
|
+
if (!isEqual(oldObj[key], newObj[key], visited)) {
|
|
105
|
+
change[key] = newObj[key]
|
|
106
|
+
changed = true
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
add[key] = newObj[key]
|
|
110
|
+
changed = true
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Pass 2: iterate old keys — find removals
|
|
115
|
+
for (const key of oldKeys) {
|
|
116
|
+
if (!(key in newObj)) {
|
|
117
|
+
remove[key] = undefined
|
|
118
|
+
changed = true
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { add, change, remove, changed }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Creates a reactive store with deeply nested reactive properties.
|
|
127
|
+
* Each property becomes its own signal (State for primitives, nested Store for objects, List for arrays).
|
|
128
|
+
* Properties are accessible directly via proxy.
|
|
129
|
+
*
|
|
130
|
+
* @since 0.15.0
|
|
131
|
+
* @param initialValue - Initial object value of the store
|
|
132
|
+
* @param options - Optional configuration for watch lifecycle
|
|
133
|
+
* @returns A Store with reactive properties
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* const user = createStore({ name: 'Alice', age: 30 });
|
|
138
|
+
* user.name.set('Bob'); // Only name subscribers react
|
|
139
|
+
* console.log(user.get()); // { name: 'Bob', age: 30 }
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
function createStore<T extends UnknownRecord>(
|
|
143
|
+
initialValue: T,
|
|
144
|
+
options?: StoreOptions,
|
|
145
|
+
): Store<T> {
|
|
146
|
+
validateSignalValue(TYPE_STORE, initialValue, isRecord)
|
|
147
|
+
|
|
148
|
+
const signals = new Map<
|
|
149
|
+
string,
|
|
150
|
+
State<unknown & {}> | Store<UnknownRecord> | List<unknown & {}>
|
|
151
|
+
>()
|
|
152
|
+
|
|
153
|
+
// --- Internal helpers ---
|
|
154
|
+
|
|
155
|
+
const addSignal = (key: string, value: unknown): void => {
|
|
156
|
+
validateSignalValue(`${TYPE_STORE} for key "${key}"`, value)
|
|
157
|
+
if (Array.isArray(value)) signals.set(key, createList(value))
|
|
158
|
+
else if (isRecord(value)) signals.set(key, createStore(value))
|
|
159
|
+
else signals.set(key, createState(value as unknown & {}))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Build current value from child signals
|
|
163
|
+
const buildValue = (): T => {
|
|
164
|
+
const record = {} as UnknownRecord
|
|
165
|
+
signals.forEach((signal, key) => {
|
|
166
|
+
record[key] = signal.get()
|
|
167
|
+
})
|
|
168
|
+
return record as T
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Structural tracking node — not a general-purpose Memo.
|
|
172
|
+
// On first get(): refresh() establishes edges from child signals.
|
|
173
|
+
// On subsequent get(): untrack(buildValue) rebuilds without re-linking.
|
|
174
|
+
// Mutation methods (add/remove/set) null out sources to force re-establishment.
|
|
175
|
+
const node: MemoNode<T> = {
|
|
176
|
+
fn: buildValue,
|
|
177
|
+
value: initialValue,
|
|
178
|
+
flags: FLAG_DIRTY,
|
|
179
|
+
sources: null,
|
|
180
|
+
sourcesTail: null,
|
|
181
|
+
sinks: null,
|
|
182
|
+
sinksTail: null,
|
|
183
|
+
equals: isEqual,
|
|
184
|
+
error: undefined,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const applyChanges = (changes: DiffResult): boolean => {
|
|
188
|
+
let structural = false
|
|
189
|
+
|
|
190
|
+
// Additions
|
|
191
|
+
for (const key in changes.add) {
|
|
192
|
+
addSignal(key, changes.add[key])
|
|
193
|
+
structural = true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Changes
|
|
197
|
+
if (Object.keys(changes.change).length) {
|
|
198
|
+
batch(() => {
|
|
199
|
+
for (const key in changes.change) {
|
|
200
|
+
const value = changes.change[key]
|
|
201
|
+
validateSignalValue(`${TYPE_STORE} for key "${key}"`, value)
|
|
202
|
+
const signal = signals.get(key)
|
|
203
|
+
if (signal) {
|
|
204
|
+
// Type changed (e.g. primitive → object or vice versa): replace signal
|
|
205
|
+
if (isRecord(value) !== isStore(signal)) {
|
|
206
|
+
addSignal(key, value)
|
|
207
|
+
structural = true
|
|
208
|
+
} else signal.set(value as never)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Removals
|
|
215
|
+
for (const key in changes.remove) {
|
|
216
|
+
signals.delete(key)
|
|
217
|
+
structural = true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (structural) {
|
|
221
|
+
node.sources = null
|
|
222
|
+
node.sourcesTail = null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return changes.changed
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Initialize ---
|
|
229
|
+
for (const key of Object.keys(initialValue))
|
|
230
|
+
addSignal(key, initialValue[key])
|
|
231
|
+
|
|
232
|
+
// --- Store object ---
|
|
233
|
+
const store: BaseStore<T> = {
|
|
234
|
+
[Symbol.toStringTag]: TYPE_STORE,
|
|
235
|
+
[Symbol.isConcatSpreadable]: false as const,
|
|
236
|
+
|
|
237
|
+
*[Symbol.iterator]() {
|
|
238
|
+
for (const key of Array.from(signals.keys())) {
|
|
239
|
+
const signal = signals.get(key)
|
|
240
|
+
if (signal)
|
|
241
|
+
yield [key, signal] as [
|
|
242
|
+
string,
|
|
243
|
+
(
|
|
244
|
+
| State<T[keyof T] & {}>
|
|
245
|
+
| Store<UnknownRecord>
|
|
246
|
+
| List<unknown & {}>
|
|
247
|
+
),
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
keys() {
|
|
253
|
+
if (activeSink) {
|
|
254
|
+
if (!node.sinks && options?.watched)
|
|
255
|
+
node.stop = options.watched()
|
|
256
|
+
link(node, activeSink)
|
|
257
|
+
}
|
|
258
|
+
return signals.keys()
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
byKey<K extends keyof T & string>(key: K) {
|
|
262
|
+
return signals.get(key) as T[K] extends readonly (infer U extends
|
|
263
|
+
{})[]
|
|
264
|
+
? List<U>
|
|
265
|
+
: T[K] extends UnknownRecord
|
|
266
|
+
? Store<T[K]>
|
|
267
|
+
: T[K] extends unknown & {}
|
|
268
|
+
? State<T[K] & {}>
|
|
269
|
+
: State<T[K] & {}> | undefined
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
get() {
|
|
273
|
+
if (activeSink) {
|
|
274
|
+
if (!node.sinks && options?.watched)
|
|
275
|
+
node.stop = options.watched()
|
|
276
|
+
link(node, activeSink)
|
|
277
|
+
}
|
|
278
|
+
if (node.sources) {
|
|
279
|
+
// Fast path: edges already established, rebuild value directly
|
|
280
|
+
// from child signals using untrack to avoid creating spurious
|
|
281
|
+
// edges to the current effect/memo consumer
|
|
282
|
+
if (node.flags) {
|
|
283
|
+
node.value = untrack(buildValue)
|
|
284
|
+
node.flags = FLAG_CLEAN
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
// First access: use refresh() to establish child → store edges
|
|
288
|
+
refresh(node as unknown as SinkNode)
|
|
289
|
+
if (node.error) throw node.error
|
|
290
|
+
}
|
|
291
|
+
return node.value
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
set(newValue: T) {
|
|
295
|
+
// Use cached value if clean, recompute if dirty
|
|
296
|
+
const currentValue =
|
|
297
|
+
node.flags & FLAG_DIRTY ? buildValue() : node.value
|
|
298
|
+
|
|
299
|
+
const changes = diffRecords(currentValue, newValue)
|
|
300
|
+
if (applyChanges(changes)) {
|
|
301
|
+
// Call propagate BEFORE marking dirty to ensure it doesn't early-return
|
|
302
|
+
propagate(node as unknown as SinkNode)
|
|
303
|
+
node.flags |= FLAG_DIRTY
|
|
304
|
+
if (batchDepth === 0) flush()
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
update(fn: (prev: T) => T) {
|
|
309
|
+
store.set(fn(store.get()))
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
add<K extends keyof T & string>(key: K, value: T[K]) {
|
|
313
|
+
if (signals.has(key))
|
|
314
|
+
throw new DuplicateKeyError(TYPE_STORE, key, value)
|
|
315
|
+
addSignal(key, value)
|
|
316
|
+
node.sources = null
|
|
317
|
+
node.sourcesTail = null
|
|
318
|
+
propagate(node as unknown as SinkNode)
|
|
319
|
+
node.flags |= FLAG_DIRTY
|
|
320
|
+
if (batchDepth === 0) flush()
|
|
321
|
+
return key
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
remove(key: string) {
|
|
325
|
+
const ok = signals.delete(key)
|
|
326
|
+
if (ok) {
|
|
327
|
+
node.sources = null
|
|
328
|
+
node.sourcesTail = null
|
|
329
|
+
propagate(node as unknown as SinkNode)
|
|
330
|
+
node.flags |= FLAG_DIRTY
|
|
331
|
+
if (batchDepth === 0) flush()
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- Proxy ---
|
|
337
|
+
return new Proxy(store, {
|
|
338
|
+
get(target, prop) {
|
|
339
|
+
if (prop in target) {
|
|
340
|
+
const value = Reflect.get(target, prop)
|
|
341
|
+
return isFunction(value) ? value.bind(target) : value
|
|
342
|
+
}
|
|
343
|
+
if (typeof prop !== 'symbol')
|
|
344
|
+
return target.byKey(prop as keyof T & string)
|
|
345
|
+
},
|
|
346
|
+
has(target, prop) {
|
|
347
|
+
if (prop in target) return true
|
|
348
|
+
return target.byKey(String(prop) as keyof T & string) !== undefined
|
|
349
|
+
},
|
|
350
|
+
ownKeys(target) {
|
|
351
|
+
return Array.from(target.keys())
|
|
352
|
+
},
|
|
353
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
354
|
+
if (prop in target)
|
|
355
|
+
return Reflect.getOwnPropertyDescriptor(target, prop)
|
|
356
|
+
if (typeof prop === 'symbol') return undefined
|
|
357
|
+
const signal = target.byKey(String(prop) as keyof T & string)
|
|
358
|
+
return signal
|
|
359
|
+
? {
|
|
360
|
+
enumerable: true,
|
|
361
|
+
configurable: true,
|
|
362
|
+
writable: true,
|
|
363
|
+
value: signal,
|
|
364
|
+
}
|
|
365
|
+
: undefined
|
|
366
|
+
},
|
|
367
|
+
}) as Store<T>
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Checks if a value is a Store signal.
|
|
372
|
+
*
|
|
373
|
+
* @since 0.15.0
|
|
374
|
+
* @param value - The value to check
|
|
375
|
+
* @returns True if the value is a Store
|
|
376
|
+
*/
|
|
377
|
+
function isStore<T extends UnknownRecord>(value: unknown): value is Store<T> {
|
|
378
|
+
return isObjectOfType(value, TYPE_STORE)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* === Exports === */
|
|
382
|
+
|
|
383
|
+
export { createStore, isStore, type Store, type StoreOptions, TYPE_STORE }
|