UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management primitives library for TypeScript.

646 lines (573 loc) 17.1 kB
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, }