UNPKG

react-smart-state

Version:

Next generation local and global state management

309 lines (258 loc) 10.3 kB
import { newId } from "./methods"; import { EventItem, WaitngItem, StateType, IEventTrigger, IFastList, ChangeType, ReactSmartStateInstanceItems, SmartStateInstanceNames, StateKeyTypeGenerator } from "./types"; export class CustomError extends Error { originalError: unknown; code?: string; details?: any; constructor(message: string, options: { originalError?: unknown; code?: string; details?: any; } = {}) { super(message); this.name = 'react-smart-state-Error'.toUpperCase(); this.originalError = options.originalError; this.code = options.code; this.details = options.details; Object.setPrototypeOf(this, new.target.prototype); } toJSON() { return { name: this.name, message: this.message, code: this.code, details: this.details, originalError: this.originalError, stack: this.stack, }; } } export class EventTrigger implements IEventTrigger { private events: Map<string, EventItem> = new Map(); timer: any = undefined; waitingEvents: IFastList<{ event: EventItem, items: IFastList<WaitngItem> }> = new FastList(); addedPaths: IFastList<string> = new FastList(); localBindedEvents: IFastList<IFastList<boolean>, string> = new FastList(); speed?: number = 2; stateType: StateType; batching: IFastList<Function, number> = new FastList<Function, number>(); seen: WeakMap<any, any> = new WeakMap(); ignoreKeys: StateKeyTypeGenerator; hardIgnoreKeys: StateKeyTypeGenerator; isMounted?: boolean; resetState?: () => void; constructor(ignoredKeys: Record<string, boolean>, hardIgnoreKeys: Record<string, boolean>) { this.ignoreKeys = ignoredKeys ?? {}; this.hardIgnoreKeys = hardIgnoreKeys ?? {}; } add(id: string, item: EventItem) { item.type = item.type ?? "Auto"; this.events.set(id, item); } remove(id: string) { this.events.delete(id); } hasChange(items: Record<string, WaitngItem>, parentState: Record<string, any>) { let hasChanges = false; let newState = parentState ?? {}; for (const { key, oldValue, newValue } of Object.values(items)) { if (oldValue !== newValue) { hasChanges = true; } newState[key] = newValue; } return { hasChanges, parentState: newState }; } async triggerSavedChanges() { clearTimeout(this.timer); // Proper debouncing if (this.batching.size > 0) return; const runHandlers = (e: EventItem, items: Record<string, WaitngItem>) => { e.func?.(items); }; const trigger = (fn: () => void) => { if (this.speed == undefined) { fn(); } else { this.timer = setTimeout(fn, this.speed); } } trigger(() => { let itemKeys = this.waitingEvents.values; this.waitingEvents.clear(); for (let { event, items } of itemKeys) { runHandlers(event, items.records()); } }); } async trigger(event: { eventId: string, event: EventItem }, key: string, oldValue: any, newValue: any) { const { eventId, event: evt } = event; const waitingItems = (this.waitingEvents.has(eventId) ? this.waitingEvents.get(eventId) : this.waitingEvents.set(eventId, { event: evt, items: new FastList() })).items; waitingItems.set(key, { key, oldValue, newValue }); if (this.batching.size > 0) return; await this.triggerSavedChanges(); } async onChange(key: string, { oldValue, newValue }, fromBind: boolean = false) { try { // if the child of the hooked key is changes, then hook should still trigger if there is a hook for it // const parts = key.split("."); for (const [eventId, event] of this.events) { if ((event.keys.AllKeys && !this.hardIgnoreKeys[key] && (event.type == "Path" || !fromBind)) || (event.keys[key] || event.keys[key + "."])) { await this.trigger({ eventId, event }, key, oldValue, newValue); } // Traverse up the key chain: "a.b.c" → "a.b" → "a" /* for (let i = parts.length - 1; i > 0; i--) { const parentKey = parts.slice(0, i).join("."); if (event.keys[parentKey]) { this.trigger({ eventId, event }, key, oldValue, newValue); break; // stop at the first match for performance } }*/ } } catch (e) { console.error(e); } } } export class FastList<T, Key extends string | number | symbol = string> implements IFastList<T, Key> { private items: Record<Key, T> = {} as Record<Key, T>; private length = 0; private cachedKeys?: Key[]; private cachedValues?: T[]; private invalidateCache() { this.cachedKeys = undefined; this.cachedValues = undefined; } clear(): this { this.items = {} as Record<Key, T>; this.length = 0; this.invalidateCache(); return this; } get hasValue(): boolean { return this.length > 0; } get values(): T[] { return this.cachedValues ?? (this.cachedValues = Object.values(this.items)); } get keys(): Key[] { return this.cachedKeys ?? (this.cachedKeys = Object.keys(this.items) as Key[]); } find(func: (item: T, key: Key) => boolean): T | undefined { for (const [key, value] of Object.entries(this.items) as [Key, T][]) { if (func(value, key)) return value; } return undefined; } findKey(func: (item: T, key: Key) => boolean): Key | undefined { for (const [key, value] of Object.entries(this.items) as [Key, T][]) { if (func(value, key)) return key; } return undefined; } delete(key: Key): this { if (key in this.items) { delete this.items[key]; this.length--; this.invalidateCache(); } return this; } get(key: Key): T | undefined { return this.items[key]; } records(): Record<Key, T> { return this.items; } record(key: Key): Record<Key, T> { const result = {} as Record<Key, T>; const val = this.get(key); if (val !== undefined) result[key] = val; return result; } has(key: Key): boolean { return key in this.items; } append(key: Key, item: T): this { const existing = this.items[key]; if (existing) Object.assign(existing, item); else this.set(key, item); return this; } set(key: Key, value: T): T { if (value === undefined) { this.delete(key); return value; } if (!(key in this.items)) { this.length++; this.invalidateCache(); } this.items[key] = value; return value; } get size(): number { return this.length; } } export class ObservableArray<T> extends Array<T> implements ReactSmartStateInstanceItems { getInstanceType(): SmartStateInstanceNames { return "react-smart-state-array" } hasInit?: boolean; constructor( private readonly parentKey: string, private readonly process: (item: T, index: number, parentKey: string) => T, private readonly onChange?: (action: ChangeType, items: T[], changes: WaitngItem) => void ) { super(); // Required to fix instanceof issues when extending Array Object.setPrototypeOf(this, ObservableArray.prototype); } private getChanges() { // always update const item: WaitngItem = { key: this.parentKey, oldValue: true, newValue: newId() } return item; } override push(...items: T[]): number { const processed = items.map((item, index) => this.process(item, index, this.parentKey)); const result = super.push(...processed); if (processed.length && this.hasInit) this.onChange?.('add', processed, this.getChanges()); return result; } override unshift(...items: T[]): number { const processed = items.map((item, index) => this.process(item, index, this.parentKey)); const result = super.unshift(...processed); if (processed.length && this.hasInit) this.onChange?.('add', processed, this.getChanges()); return result; } override pop(): T | undefined { const removed = super.pop(); if (removed !== undefined && this.hasInit) this.onChange?.('remove', [removed], this.getChanges()); return removed; } override shift(): T | undefined { const removed = super.shift(); if (removed !== undefined && this.hasInit) this.onChange?.('remove', [removed], this.getChanges()); return removed; } override splice(start: number, deleteCount?: number, ...items: T[]): T[] { const processed = items.map((item, index) => this.process(item, index, this.parentKey)); const removed = super.splice(start, deleteCount ?? this.length, ...processed); if (removed.length && this.hasInit) this.onChange?.('remove', removed, this.getChanges()); if (processed.length && this.hasInit) this.onChange?.('add', processed, this.getChanges()); return removed; } clear(): void { if (this.length > 0) { const removed = this.splice(0); if (this.hasInit) this.onChange?.('remove', removed, this.getChanges()); } } }