@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
373 lines (334 loc) • 9.63 kB
text/typescript
import { DuplicateKeyError, validateSignalValue } from '../errors'
import {
batch,
batchDepth,
type Cleanup,
DEEP_EQUALITY,
FLAG_CLEAN,
FLAG_DIRTY,
FLAG_RELINK,
flush,
type MemoNode,
makeSubscribe,
propagate,
refresh,
type SinkNode,
TYPE_STORE,
untrack,
} from '../graph'
import { isRecord, isSignalOfType } from '../util'
import {
createList,
type DiffResult,
type List,
type UnknownRecord,
} from './list'
import { createState, type State } from './state'
/* === Types === */
/**
* Configuration options for `createStore`.
*/
type StoreOptions = {
/** Invoked when the store gains its first downstream subscriber; returns a cleanup called when the last one unsubscribes. */
watched?: () => Cleanup
}
type BaseStore<T extends UnknownRecord> = {
readonly [Symbol.toStringTag]: 'Store'
readonly [Symbol.isConcatSpreadable]: false
[Symbol.iterator](): IterableIterator<
[
string,
State<T[keyof T] & {}> | Store<UnknownRecord> | List<unknown & {}>,
]
>
keys(): IterableIterator<string>
byKey<K extends keyof T & string>(
key: K,
): T[K] extends readonly (infer U extends {})[]
? List<U>
: T[K] extends UnknownRecord
? Store<T[K]>
: T[K] extends unknown & {}
? State<T[K] & {}>
: State<T[K] & {}> | undefined
get(): T
set(next: T): void
update(fn: (prev: T) => T): void
add<K extends keyof T & string>(key: K, value: T[K]): K
remove(key: string): void
}
/**
* A reactive object with per-property reactivity.
* Each property is wrapped as a `State`, nested `Store`, or `List` signal, accessible directly via proxy.
* Updating one property only re-runs effects that read that property.
*
* @template T - The plain-object type whose properties become reactive signals
*/
type Store<T extends UnknownRecord> = BaseStore<T> & {
[K in keyof T]: T[K] extends readonly (infer U extends {})[]
? List<U>
: T[K] extends UnknownRecord
? Store<T[K]>
: T[K] extends unknown & {}
? State<T[K] & {}>
: State<T[K] & {}> | undefined
}
/* === Functions === */
/** Diff two records and return granular changes */
function diffRecords<T extends UnknownRecord>(prev: T, next: T): DiffResult {
const add = {} as UnknownRecord
const change = {} as UnknownRecord
const remove = {} as UnknownRecord
let changed = false
const prevKeys = Object.keys(prev)
const nextKeys = Object.keys(next)
// Pass 1: iterate new keys — find additions and changes
for (const key of nextKeys) {
if (key in prev) {
if (
!DEEP_EQUALITY(
prev[key] as unknown & {},
next[key] as unknown & {},
)
) {
change[key] = next[key]
changed = true
}
} else {
add[key] = next[key]
changed = true
}
}
// Pass 2: iterate old keys — find removals
for (const key of prevKeys) {
if (!(key in next)) {
remove[key] = undefined
changed = true
}
}
return { add, change, remove, changed }
}
/**
* Creates a reactive store with deeply nested reactive properties.
* Each property becomes its own signal (State for primitives, nested Store for objects, List for arrays).
* Properties are accessible directly via proxy.
*
* @since 0.15.0
* @param value - Initial object value of the store
* @param options - Optional configuration for watch lifecycle
* @returns A Store with reactive properties
*
* @example
* ```ts
* const user = createStore({ name: 'Alice', age: 30 });
* user.name.set('Bob'); // Only name subscribers react
* console.log(user.get()); // { name: 'Bob', age: 30 }
* ```
*/
function createStore<T extends UnknownRecord>(
value: T,
options?: StoreOptions,
): Store<T> {
validateSignalValue(TYPE_STORE, value, isRecord)
const signals = new Map<
string,
State<unknown & {}> | Store<UnknownRecord> | List<unknown & {}>
>()
// --- Internal helpers ---
const addSignal = (key: string, val: unknown): void => {
validateSignalValue(`${TYPE_STORE} for key "${key}"`, val)
if (Array.isArray(val)) signals.set(key, createList(val))
else if (isRecord(val)) signals.set(key, createStore(val))
else signals.set(key, createState(val as unknown & {}))
}
// Build current value from child signals
const buildValue = (): T => {
const record = {} as UnknownRecord
for (const [key, signal] of signals) record[key] = signal.get()
return record as T
}
// Structural tracking node — not a general-purpose Memo.
// On first get(): refresh() establishes edges from child signals.
// On subsequent get(): untrack(buildValue) rebuilds without re-linking.
// Mutation methods set FLAG_RELINK to force re-establishment on next read.
const node: MemoNode<T> = {
fn: buildValue,
value,
flags: FLAG_DIRTY,
sources: null,
sourcesTail: null,
sinks: null,
sinksTail: null,
equals: DEEP_EQUALITY,
error: undefined,
}
const applyChanges = (changes: DiffResult): boolean => {
let structural = false
// Additions
for (const key in changes.add) {
addSignal(key, changes.add[key])
structural = true
}
// Changes
let hasChange = false
for (const _key in changes.change) {
hasChange = true
break
}
if (hasChange) {
batch(() => {
for (const key in changes.change) {
const val = changes.change[key]
validateSignalValue(`${TYPE_STORE} for key "${key}"`, val)
const signal = signals.get(key)
if (signal) {
// Type changed (e.g. primitive → object or vice versa): replace signal
if (isRecord(val) !== isStore(signal)) {
addSignal(key, val)
structural = true
} else signal.set(val as never)
}
}
})
}
// Removals
for (const key in changes.remove) {
signals.delete(key)
structural = true
}
if (structural) node.flags |= FLAG_RELINK
return changes.changed
}
const subscribe = makeSubscribe(node, options?.watched)
// --- Initialize ---
for (const key of Object.keys(value)) addSignal(key, value[key])
// --- Store object ---
const store: BaseStore<T> = {
[Symbol.toStringTag]: TYPE_STORE,
[Symbol.isConcatSpreadable]: false as const,
*[Symbol.iterator]() {
for (const [key, signal] of signals) {
yield [key, signal] as [
string,
(
| State<T[keyof T] & {}>
| Store<UnknownRecord>
| List<unknown & {}>
),
]
}
},
keys() {
subscribe()
return signals.keys()
},
byKey<K extends keyof T & string>(key: K) {
return signals.get(key) as T[K] extends readonly (infer U extends
{})[]
? List<U>
: T[K] extends UnknownRecord
? Store<T[K]>
: T[K] extends unknown & {}
? State<T[K] & {}>
: State<T[K] & {}> | undefined
},
get() {
subscribe()
if (node.sources) {
// Fast path: edges already established, rebuild value directly
// from child signals using untrack to avoid creating spurious
// edges to the current effect/memo consumer
if (node.flags) {
const relink = node.flags & FLAG_RELINK
node.value = untrack(buildValue)
if (relink) {
// Structural mutation added/removed child signals —
// tracked recompute so link() adds new edges and
// trimSources() removes stale ones without orphaning.
node.flags = FLAG_DIRTY
refresh(node as unknown as SinkNode)
if (node.error) throw node.error
} else {
node.flags = FLAG_CLEAN
}
}
} else {
// First access: use refresh() to establish child → store edges
refresh(node as unknown as SinkNode)
if (node.error) throw node.error
}
return node.value
},
set(next: T) {
// Use cached value if clean, recompute if dirty
const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value
const changes = diffRecords(prev, next)
if (applyChanges(changes)) {
node.flags |= FLAG_DIRTY
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
if (batchDepth === 0) flush()
}
},
update(fn: (prev: T) => T) {
store.set(fn(store.get()))
},
add<K extends keyof T & string>(key: K, value: T[K]) {
if (signals.has(key))
throw new DuplicateKeyError(TYPE_STORE, key, value)
addSignal(key, value)
node.flags |= FLAG_DIRTY | FLAG_RELINK
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
if (batchDepth === 0) flush()
return key
},
remove(key: string) {
const ok = signals.delete(key)
if (ok) {
node.flags |= FLAG_DIRTY | FLAG_RELINK
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
if (batchDepth === 0) flush()
}
},
}
// --- Proxy ---
return new Proxy(store, {
get(target, prop) {
if (prop in target) return Reflect.get(target, prop)
if (typeof prop !== 'symbol')
return target.byKey(prop as keyof T & string)
},
has(target, prop) {
if (prop in target) return true
return target.byKey(String(prop) as keyof T & string) !== undefined
},
ownKeys(target) {
return Array.from(target.keys())
},
getOwnPropertyDescriptor(target, prop) {
if (prop in target)
return Reflect.getOwnPropertyDescriptor(target, prop)
if (typeof prop === 'symbol') return undefined
const signal = target.byKey(String(prop) as keyof T & string)
return signal
? {
enumerable: true,
configurable: true,
writable: true,
value: signal,
}
: undefined
},
}) as Store<T>
}
/**
* Checks if a value is a Store signal.
*
* @since 0.15.0
* @param value - The value to check
* @returns True if the value is a Store
*/
function isStore<T extends UnknownRecord>(value: unknown): value is Store<T> {
return isSignalOfType(value, TYPE_STORE)
}
/* === Exports === */
export { createStore, isStore, type Store, type StoreOptions, TYPE_STORE }