@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
646 lines (573 loc) • 17.1 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_LIST,
untrack,
} from '../graph'
import type { MutableSignal } from '../signal'
import { isFunction, isSignalOfType } from '../util'
import {
type Collection,
type CollectionSource,
type DeriveCollectionCallback,
deriveCollection,
} from './collection'
import { createState } from './state'
/* === Types === */
type UnknownRecord = Record<string, unknown>
type DiffResult = {
changed: boolean
add: UnknownRecord
change: UnknownRecord
remove: UnknownRecord
}
/**
* Key generation strategy for `createList` items.
* A string value is used as a prefix for auto-incremented keys (`prefix0`, `prefix1`, …).
* A function receives each item and returns a stable string key, or `undefined` to fall back to auto-increment.
*
* @template T - The type of items in the list
*/
type KeyConfig<T> = string | ((item: T) => string | undefined)
/**
* Configuration options for `createList`.
*
* @template T - The type of items in the list
*/
type ListOptions<
T extends {},
S extends MutableSignal<T> = MutableSignal<T>,
> = {
/** Key generation strategy. A string prefix or a function `(item) => string | undefined`. Defaults to auto-increment. */
keyConfig?: KeyConfig<T>
/** Lifecycle callback invoked when the list gains its first downstream subscriber. Must return a cleanup function. */
watched?: () => Cleanup
/** Equality function for item state signals. Defaults to `DEEP_EQUALITY`. */
itemEquals?: (a: T, b: T) => boolean
/** Factory for per-item signals. Defaults to `createState`. */
createItem?: (value: T) => S
}
/**
* A reactive ordered array with stable keys and per-item reactivity.
* Each item is a `MutableSignal<T>`; structural changes (add/remove/sort) propagate reactively.
*
* @template T - The type of items in the list
*/
type List<T extends {}, S extends MutableSignal<T> = MutableSignal<T>> = {
readonly [Symbol.toStringTag]: 'List'
readonly [Symbol.isConcatSpreadable]: true
[Symbol.iterator](): IterableIterator<S>
readonly length: number
get(): T[]
set(next: T[]): void
update(fn: (prev: T[]) => T[]): void
at(index: number): S | undefined
keys(): IterableIterator<string>
byKey(key: string): S | undefined
keyAt(index: number): string | undefined
indexOfKey(key: string): number
add(value: T): string
remove(keyOrIndex: string | number): void
/**
* Updates an existing item by key, propagating to all subscribers.
* No-op if the key does not exist or the value is reference-equal to the current value.
* @param key - Stable key of the item to update
* @param value - New value for the item
*/
replace(key: string, value: T): void
sort(compareFn?: (a: T, b: T) => number): void
splice(start: number, deleteCount?: number, ...items: T[]): T[]
deriveCollection<R extends {}>(
callback: (sourceValue: T) => R,
): Collection<R>
deriveCollection<R extends {}>(
callback: (sourceValue: T, abort: AbortSignal) => Promise<R>,
): Collection<R>
}
/* === Functions === */
/** Shallow equality check for string arrays */
function keysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
return true
}
function getKeyGenerator<T extends {}>(
keyConfig?: KeyConfig<T>,
): [(item: T) => string, boolean] {
let keyCounter = 0
const contentBased = typeof keyConfig === 'function'
return [
typeof keyConfig === 'string'
? () => `${keyConfig}${keyCounter++}`
: contentBased
? (item: T) => keyConfig(item) || String(keyCounter++)
: () => String(keyCounter++),
contentBased,
]
}
/**
* Fast diff for positional (non-content-based) keys.
* Avoids Map/Set allocation by iterating both arrays in one pass.
*/
function diffPositional<T extends {}>(
prev: T[],
next: T[],
prevKeys: string[],
generateKey: (item: T) => string,
itemEquals: (a: T, b: T) => boolean,
): DiffResult & { newKeys: string[] } {
const add = {} as UnknownRecord
const change = {} as UnknownRecord
const remove = {} as UnknownRecord
const nextKeys: string[] = []
let changed = false
const minLen = Math.min(prev.length, next.length)
for (let i = 0; i < minLen; i++) {
// biome-ignore lint/style/noNonNullAssertion: bounded by minLen
const key = prevKeys[i]!
nextKeys.push(key)
// biome-ignore lint/style/noNonNullAssertion: bounded by minLen
if (!itemEquals(prev[i]!, next[i]!)) {
// biome-ignore lint/style/noNonNullAssertion: bounded by minLen
change[key] = next[i]!
changed = true
}
}
for (let i = minLen; i < next.length; i++) {
// biome-ignore lint/style/noNonNullAssertion: bounded by next.length
const val = next[i]!
const key = generateKey(val)
nextKeys.push(key)
add[key] = val
changed = true
}
for (let i = minLen; i < prev.length; i++) {
// biome-ignore lint/style/noNonNullAssertion: bounded by prev.length
remove[prevKeys[i]!] = null
changed = true
}
return { add, change, remove, newKeys: nextKeys, changed }
}
/**
* Compares two arrays using existing keys and returns differences as a DiffResult.
* Avoids object conversion by working directly with arrays and keys.
*
* @since 0.18.0
* @param prev - The old array
* @param next - The new array
* @param prevKeys - Current keys array (may be sparse or shorter than oldArray)
* @param generateKey - Function to generate keys for new items
* @param contentBased - When true, always use generateKey (content-based keys);
* when false, reuse positional keys from currentKeys (synthetic keys)
* @returns The differences in DiffResult format plus updated keys array
*/
function diffArrays<T extends {}>(
prev: T[],
next: T[],
prevKeys: string[],
generateKey: (item: T) => string,
contentBased: boolean,
itemEquals: (a: T, b: T) => boolean,
): DiffResult & { newKeys: string[] } {
if (!contentBased)
return diffPositional(prev, next, prevKeys, generateKey, itemEquals)
const add = {} as UnknownRecord
const change = {} as UnknownRecord
const remove = {} as UnknownRecord
const nextKeys: string[] = []
let changed = false
// Build a map of old values by key for quick lookup
const prevByKey = new Map<string, T>()
for (let i = 0; i < prev.length; i++) {
const key = prevKeys[i]
const item = prev[i]
if (key && item !== undefined) prevByKey.set(key, item)
}
// Track which old keys we've seen
const seenKeys = new Set<string>()
// Process new array and build new keys array
for (let i = 0; i < next.length; i++) {
const val = next[i]
if (val === undefined) continue
const key = generateKey(val)
if (seenKeys.has(key)) throw new DuplicateKeyError(TYPE_LIST, key, val)
nextKeys.push(key)
seenKeys.add(key)
// Check if this key existed before
if (!prevByKey.has(key)) {
add[key] = val
changed = true
} else if (!itemEquals(prevByKey.get(key) as T, val)) {
change[key] = val
changed = true
}
}
// Find removed keys (existed in old but not in new)
for (const [key] of prevByKey) {
if (!seenKeys.has(key)) {
remove[key] = null
changed = true
}
}
// Detect reorder even when no values changed
if (!changed && !keysEqual(prevKeys, nextKeys)) changed = true
return { add, change, remove, newKeys: nextKeys, changed }
}
/**
* Creates a reactive list with stable keys and per-item reactivity.
*
* @since 0.18.0
* @param value - Initial array of items
* @param options.keyConfig - Key generation strategy: string prefix or `(item) => string | undefined`. Defaults to auto-increment.
* @param options.watched - Lifecycle callback invoked on first subscriber; must return a cleanup function called on last unsubscribe.
* @returns A `List` signal with reactive per-item `MutableSignal`s
*/
function createList<
T extends {},
S extends MutableSignal<T> = MutableSignal<T>,
>(value: T[], options?: ListOptions<T, S>): List<T, S> {
validateSignalValue(TYPE_LIST, value, Array.isArray)
const signals = new Map<string, S>()
let keys: string[] = []
const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig)
const itemEquals = options?.itemEquals ?? DEEP_EQUALITY
const itemFactory = (options?.createItem ??
((item: T) => createState(item, { equals: itemEquals }))) as (
value: T,
) => S
// --- Internal helpers ---
// Build current value from child signals
const buildValue = (): T[] => {
const result: T[] = []
for (const key of keys) {
const v = signals.get(key)?.get()
if (v !== undefined) result.push(v)
}
return result
}
// 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) {
const val = changes.add[key] as T
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val)
signals.set(key, itemFactory(val))
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_LIST} item for key "${key}"`,
val,
)
const signal = signals.get(key)
if (signal) signal.set(val as T)
}
})
}
// Removals
for (const key in changes.remove) {
signals.delete(key)
const index = keys.indexOf(key)
if (index !== -1) keys.splice(index, 1)
structural = true
}
if (structural) node.flags |= FLAG_RELINK
return changes.changed
}
const subscribe = makeSubscribe(node, options?.watched)
// --- Initialize ---
for (let i = 0; i < value.length; i++) {
const val = value[i]
if (val === undefined) continue
let key = keys[i]
if (!key) {
key = generateKey(val)
keys[i] = key
}
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val)
signals.set(key, itemFactory(val))
}
// Starts clean: mutation methods (add/remove/set/splice) explicitly call
// propagate() + invalidate edges, so refresh() on first get() is not needed.
node.value = value
node.flags = 0
// --- List object ---
const list: List<T, S> = {
[Symbol.toStringTag]: TYPE_LIST,
[Symbol.isConcatSpreadable]: true as const,
*[Symbol.iterator]() {
for (const key of keys) {
const signal = signals.get(key)
if (signal) yield signal
}
},
get length() {
subscribe()
return keys.length
},
get() {
subscribe()
if (node.sources) {
// Fast path: edges already established, rebuild value directly
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 → list edges
refresh(node as unknown as SinkNode)
if (node.error) throw node.error
}
return node.value
},
set(next: T[]) {
const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value
const changes = diffArrays(
prev,
next,
keys,
generateKey,
contentBased,
itemEquals,
)
if (changes.changed) {
keys = changes.newKeys
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[]) {
list.set(fn(list.get()))
},
at(index: number) {
const key = keys[index]
return key !== undefined ? signals.get(key) : undefined
},
keys() {
subscribe()
return keys.values()
},
byKey(key: string) {
return signals.get(key)
},
keyAt(index: number) {
return keys[index]
},
indexOfKey(key: string) {
return keys.indexOf(key)
},
add(value: T) {
const key = generateKey(value)
if (signals.has(key))
throw new DuplicateKeyError(TYPE_LIST, key, value)
keys.push(key)
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
signals.set(key, itemFactory(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(keyOrIndex: string | number) {
const key =
typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex
if (key === undefined) return
const ok = signals.delete(key)
if (ok) {
const index =
typeof keyOrIndex === 'number'
? keyOrIndex
: keys.indexOf(key)
if (index >= 0) keys.splice(index, 1)
node.flags |= FLAG_DIRTY | FLAG_RELINK
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
if (batchDepth === 0) flush()
}
},
replace(key: string, value: T) {
const signal = signals.get(key)
if (!signal) return
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
if (
itemEquals(
untrack(() => signal.get()),
value,
)
)
return
signal.set(value)
node.flags |= FLAG_DIRTY
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
if (batchDepth === 0) flush()
},
sort(compareFn?: (a: T, b: T) => number) {
const entries: [string, T][] = []
for (const key of keys) {
const v = signals.get(key)?.get()
if (v !== undefined) entries.push([key, v])
}
entries.sort(
isFunction(compareFn)
? (a, b) => compareFn(a[1], b[1])
: (a, b) => String(a[1]).localeCompare(String(b[1])),
)
const newOrder: string[] = []
for (const [key] of entries) newOrder.push(key)
if (!keysEqual(keys, newOrder)) {
keys = newOrder
node.flags |= FLAG_DIRTY
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
if (batchDepth === 0) flush()
}
},
splice(start: number, deleteCount?: number, ...items: T[]) {
const length = keys.length
const actualStart =
start < 0
? Math.max(0, length + start)
: Math.min(start, length)
const actualDeleteCount = Math.max(
0,
Math.min(
deleteCount ??
Math.max(0, length - Math.max(0, actualStart)),
length - actualStart,
),
)
const add = {} as Record<string, T>
const remove = {} as Record<string, T>
let hasRemove = false
// Collect items to delete
for (let i = 0; i < actualDeleteCount; i++) {
const index = actualStart + i
const key = keys[index]
if (key) {
const signal = signals.get(key)
if (signal) {
remove[key] = signal.get()
hasRemove = true
}
}
}
// Build new key order
const newOrder = keys.slice(0, actualStart)
const change = {} as Record<string, T>
let hasAdd = false
let hasChange = false
for (const item of items) {
const key = generateKey(item)
if (key in remove) {
// Same key removed and re-inserted: route to change, not add+remove
delete remove[key]
change[key] = item
hasChange = true
} else if (signals.has(key)) {
throw new DuplicateKeyError(TYPE_LIST, key, item)
} else {
add[key] = item
hasAdd = true
}
newOrder.push(key)
}
newOrder.push(...keys.slice(actualStart + actualDeleteCount))
const changed = hasAdd || hasRemove || hasChange
if (changed) {
applyChanges({
add,
change,
remove,
changed,
})
keys = newOrder
node.flags |= FLAG_DIRTY
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
if (batchDepth === 0) flush()
}
return Object.values(remove)
},
deriveCollection<R extends {}>(
cb: DeriveCollectionCallback<R, T>,
): Collection<R> {
return (
deriveCollection as <T2 extends {}, U2 extends {}>(
source: CollectionSource<U2>,
callback: DeriveCollectionCallback<T2, U2>,
) => Collection<T2>
)(list, cb)
},
}
return list
}
/**
* Checks if a value is a List signal.
*
* @since 0.15.0
* @param value - The value to check
* @returns True if the value is a List
*/
function isList<T extends {}, S extends MutableSignal<T> = MutableSignal<T>>(
value: unknown,
): value is List<T, S> {
return isSignalOfType(value, TYPE_LIST)
}
/* === Exports === */
export {
type DiffResult,
type KeyConfig,
type List,
type ListOptions,
type UnknownRecord,
createList,
isList,
getKeyGenerator,
keysEqual,
TYPE_LIST,
}