UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

671 lines (575 loc) 14.4 kB
/** * @fileoverview OrdoJS Reactivity System - Signal-based reactive state management * @author OrdoJS Framework Team */ /** * Cleanup function type */ export type EffectCleanup = () => void; /** * Unsubscribe function type */ export type Unsubscribe = () => void; /** * Signal interface for reactive values */ export interface Signal<T> { /** Current value */ readonly value: T; /** Subscribe to value changes */ subscribe(callback: (value: T) => void): Unsubscribe; /** Update value with new value */ set(value: T): void; /** Update value with updater function */ update(updater: (current: T) => T): void; /** Get current value (same as .value) */ get(): T; /** Check if signal has subscribers */ hasSubscribers(): boolean; } /** * Computed signal interface for derived values */ export interface ComputedSignal<T> extends Omit<Signal<T>, 'set' | 'update'> { /** Recompute the value */ recompute(): void; /** Get dependencies */ getDependencies(): Signal<any>[]; } /** * Effect options */ export interface EffectOptions { /** Run effect immediately */ immediate?: boolean; /** Effect name for debugging */ name?: string; /** Cleanup function */ onCleanup?: () => void; } /** * Batch options */ export interface BatchOptions { /** Batch name for debugging */ name?: string; /** Priority level */ priority?: 'low' | 'normal' | 'high'; } /** * Global reactivity context */ interface ReactivityContext { /** Currently running effect */ currentEffect: EffectInstance | null; /** Effect stack for nested effects */ effectStack: EffectInstance[]; /** Batch update queue */ batchQueue: Set<Signal<any>>; /** Is currently batching */ isBatching: boolean; /** Scheduled batch flush */ batchFlushScheduled: boolean; } /** * Effect instance */ interface EffectInstance { /** Effect function */ fn: () => void; /** Dependencies */ dependencies: Set<Signal<any>>; /** Cleanup function */ cleanup?: () => void; /** Effect options */ options: EffectOptions; /** Is active */ active: boolean; } /** * Global reactivity context */ const reactivityContext: ReactivityContext = { currentEffect: null, effectStack: [], batchQueue: new Set(), isBatching: false, batchFlushScheduled: false }; /** * Signal implementation */ class SignalImpl<T> implements Signal<T> { private _value: T; private subscribers = new Set<(value: T) => void>(); private effects = new Set<EffectInstance>(); constructor(initialValue: T) { this._value = initialValue; } get value(): T { this.track(); return this._value; } get(): T { return this.value; } set(newValue: T): void { if (Object.is(this._value, newValue)) { return; } this._value = newValue; this.trigger(); } update(updater: (current: T) => T): void { this.set(updater(this._value)); } subscribe(callback: (value: T) => void): Unsubscribe { this.subscribers.add(callback); return () => { this.subscribers.delete(callback); }; } hasSubscribers(): boolean { return this.subscribers.size > 0 || this.effects.size > 0; } private track(): void { const currentEffect = reactivityContext.currentEffect; if (currentEffect) { this.effects.add(currentEffect); currentEffect.dependencies.add(this); } } private trigger(): void { if (reactivityContext.isBatching) { reactivityContext.batchQueue.add(this); this.scheduleBatchFlush(); return; } this.flush(); } private flush(): void { // Notify subscribers for (const callback of this.subscribers) { try { callback(this._value); } catch (error) { console.error('Error in signal subscriber:', error); } } // Run effects for (const effect of this.effects) { if (effect.active) { try { runEffect(effect); } catch (error) { console.error('Error in effect:', error); } } } } private scheduleBatchFlush(): void { if (!reactivityContext.batchFlushScheduled) { reactivityContext.batchFlushScheduled = true; queueMicrotask(() => { flushBatch(); }); } } } /** * Computed signal implementation */ class ComputedSignalImpl<T> implements ComputedSignal<T> { private _value: T; private _computed = false; private dependencies = new Set<Signal<any>>(); private subscribers = new Set<(value: T) => void>(); private effects = new Set<EffectInstance>(); constructor(private computeFn: () => T) { this._value = this.compute(); } get value(): T { if (!this._computed) { this.recompute(); } this.track(); return this._value; } get(): T { return this.value; } subscribe(callback: (value: T) => void): Unsubscribe { this.subscribers.add(callback); return () => { this.subscribers.delete(callback); }; } hasSubscribers(): boolean { return this.subscribers.size > 0 || this.effects.size > 0; } recompute(): void { const newValue = this.compute(); if (!Object.is(this._value, newValue)) { this._value = newValue; this.trigger(); } } getDependencies(): Signal<any>[] { return Array.from(this.dependencies); } private compute(): T { // Clear old dependencies this.dependencies.clear(); // Track new dependencies const prevEffect = reactivityContext.currentEffect; const computedEffect: EffectInstance = { fn: () => this.recompute(), dependencies: new Set(), options: { name: 'computed' }, active: true }; reactivityContext.currentEffect = computedEffect; try { const result = this.computeFn(); this.dependencies = computedEffect.dependencies; this._computed = true; return result; } finally { reactivityContext.currentEffect = prevEffect; } } private track(): void { const currentEffect = reactivityContext.currentEffect; if (currentEffect) { this.effects.add(currentEffect); currentEffect.dependencies.add(this as any); } } private trigger(): void { if (reactivityContext.isBatching) { reactivityContext.batchQueue.add(this as any); this.scheduleBatchFlush(); return; } this.flush(); } private flush(): void { // Notify subscribers for (const callback of this.subscribers) { try { callback(this._value); } catch (error) { console.error('Error in computed subscriber:', error); } } // Run effects for (const effect of this.effects) { if (effect.active) { try { runEffect(effect); } catch (error) { console.error('Error in effect:', error); } } } } private scheduleBatchFlush(): void { if (!reactivityContext.batchFlushScheduled) { reactivityContext.batchFlushScheduled = true; queueMicrotask(() => { flushBatch(); }); } } } /** * Run an effect */ function runEffect(effect: EffectInstance): void { if (!effect.active) return; // Cleanup previous run if (effect.cleanup) { effect.cleanup(); effect.cleanup = undefined; } // Clear dependencies for (const dep of effect.dependencies) { if ('effects' in dep) { (dep as any).effects.delete(effect); } } effect.dependencies.clear(); // Run effect const prevEffect = reactivityContext.currentEffect; reactivityContext.currentEffect = effect; reactivityContext.effectStack.push(effect); try { effect.fn(); } finally { reactivityContext.currentEffect = prevEffect; reactivityContext.effectStack.pop(); } } /** * Flush batch updates */ function flushBatch(): void { if (!reactivityContext.isBatching) return; const signals = Array.from(reactivityContext.batchQueue); reactivityContext.batchQueue.clear(); reactivityContext.batchFlushScheduled = false; for (const signal of signals) { (signal as any).flush(); } } /** * Create a reactive signal */ export function signal<T>(initialValue: T): Signal<T> { return new SignalImpl(initialValue); } /** * Create a computed signal */ export function computed<T>(computeFn: () => T): ComputedSignal<T> { return new ComputedSignalImpl(computeFn); } /** * Create an effect that runs when dependencies change */ export function effect(fn: () => void, options: EffectOptions = {}): EffectCleanup { const effectInstance: EffectInstance = { fn, dependencies: new Set(), options: { immediate: true, ...options }, active: true }; if (options.onCleanup) { effectInstance.cleanup = options.onCleanup; } // Run immediately if requested if (effectInstance.options.immediate) { runEffect(effectInstance); } return () => { effectInstance.active = false; // Cleanup if (effectInstance.cleanup) { effectInstance.cleanup(); } // Remove from dependencies for (const dep of effectInstance.dependencies) { if ('effects' in dep) { (dep as any).effects.delete(effectInstance); } } }; } /** * Batch multiple updates together */ export function batch<T>(fn: () => T, options: BatchOptions = {}): T { if (reactivityContext.isBatching) { // Already batching, just run the function return fn(); } reactivityContext.isBatching = true; try { const result = fn(); // Flush all batched updates flushBatch(); return result; } finally { reactivityContext.isBatching = false; } } /** * Create a derived signal that depends on other signals */ export function derived<T>(fn: () => T): ComputedSignal<T> { return computed(fn); } /** * Create a writable derived signal */ export function writableDerived<T>( getter: () => T, setter: (value: T) => void ): Signal<T> { const computedValue = computed(getter); return { get value() { return computedValue.value; }, get: () => computedValue.value, set: setter, update: (updater) => setter(updater(computedValue.value)), subscribe: (callback) => computedValue.subscribe(callback), hasSubscribers: () => computedValue.hasSubscribers() }; } /** * Create a signal that persists to localStorage */ export function persistentSignal<T>( key: string, initialValue: T, storage: Storage = localStorage ): Signal<T> { // Try to load from storage let storedValue = initialValue; try { const stored = storage.getItem(key); if (stored !== null) { storedValue = JSON.parse(stored); } } catch (error) { console.warn(`Failed to load persistent signal "${key}":`, error); } const sig = signal(storedValue); // Subscribe to changes and persist sig.subscribe((value) => { try { storage.setItem(key, JSON.stringify(value)); } catch (error) { console.warn(`Failed to persist signal "${key}":`, error); } }); return sig; } /** * Create a debounced signal */ export function debouncedSignal<T>( source: Signal<T>, delay: number ): Signal<T> { const debounced = signal(source.value); let timeoutId: number | undefined; source.subscribe((value) => { if (timeoutId !== undefined) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { debounced.set(value); timeoutId = undefined; }, delay) as any; }); return debounced; } /** * Create a throttled signal */ export function throttledSignal<T>( source: Signal<T>, delay: number ): Signal<T> { const throttled = signal(source.value); let lastUpdate = 0; let timeoutId: number | undefined; source.subscribe((value) => { const now = Date.now(); const timeSinceLastUpdate = now - lastUpdate; if (timeSinceLastUpdate >= delay) { throttled.set(value); lastUpdate = now; } else { if (timeoutId !== undefined) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { throttled.set(value); lastUpdate = Date.now(); timeoutId = undefined; }, delay - timeSinceLastUpdate) as any; } }); return throttled; } /** * Combine multiple signals into one */ export function combineSignals<T extends readonly Signal<any>[]>( signals: T ): ComputedSignal<{ [K in keyof T]: T[K] extends Signal<infer U> ? U : never }> { return computed(() => { return signals.map(sig => sig.value) as any; }); } /** * Create a signal from a promise */ export function fromPromise<T>( promise: Promise<T>, initialValue?: T ): Signal<{ loading: boolean; data?: T; error?: Error }> { const state = signal<{ loading: boolean; data?: T; error?: Error }>({ loading: true, data: initialValue }); promise .then((data) => { state.set({ loading: false, data }); }) .catch((error) => { state.set({ loading: false, error }); }); return state; } /** * Create a signal from an event target */ export function fromEvent<T = Event>( target: EventTarget, eventName: string, options?: AddEventListenerOptions ): Signal<T | null> { const eventSignal = signal<T | null>(null); const handler = (event: Event) => { eventSignal.set(event as T); }; target.addEventListener(eventName, handler, options); // Return signal with cleanup const originalSubscribe = eventSignal.subscribe; eventSignal.subscribe = (callback) => { const unsubscribe = originalSubscribe.call(eventSignal, callback); return () => { unsubscribe(); target.removeEventListener(eventName, handler, options); }; }; return eventSignal; } /** * Reactivity utilities */ export const reactivity = { signal, computed, effect, batch, derived, writableDerived, persistentSignal, debouncedSignal, throttledSignal, combineSignals, fromPromise, fromEvent }; /** * Get current reactivity context (for debugging) */ export function getReactivityContext(): Readonly<ReactivityContext> { return { ...reactivityContext }; } /** * Reset reactivity context (for testing) */ export function resetReactivityContext(): void { reactivityContext.currentEffect = null; reactivityContext.effectStack = []; reactivityContext.batchQueue.clear(); reactivityContext.isBatching = false; reactivityContext.batchFlushScheduled = false; }