@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
671 lines (575 loc) • 14.4 kB
text/typescript
/**
* @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;
}