feat(stateprocess): add managed state processes with lifecycle controls, scheduled actions, and disposal safety
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartstate',
|
||||
version: '2.2.1',
|
||||
version: '2.3.0',
|
||||
description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './smartstate.classes.statepart.js';
|
||||
export * from './smartstate.classes.stateaction.js';
|
||||
export * from './smartstate.classes.computed.js';
|
||||
export * from './smartstate.contextprovider.js';
|
||||
export * from './smartstate.classes.stateprocess.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { combineLatest, map } from 'rxjs';
|
||||
import { combineLatest, map, distinctUntilChanged } from 'rxjs';
|
||||
import type { StatePart } from './smartstate.classes.statepart.js';
|
||||
|
||||
/**
|
||||
@@ -12,5 +12,6 @@ export function computed<TResult>(
|
||||
): plugins.smartrx.rxjs.Observable<TResult> {
|
||||
return combineLatest(sources.map((sp) => sp.select())).pipe(
|
||||
map((states) => computeFn(...states)),
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
) as plugins.smartrx.rxjs.Observable<TResult>;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,11 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
const pending = [...this.pendingNotifications];
|
||||
this.pendingNotifications.clear();
|
||||
for (const sp of pending) {
|
||||
await sp.notifyChange();
|
||||
try {
|
||||
await sp.notifyChange();
|
||||
} catch (err) {
|
||||
console.error(`Error flushing notification for state part:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -69,6 +73,21 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
return computed(sources, computeFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* disposes all state parts and clears internal state
|
||||
*/
|
||||
public dispose(): void {
|
||||
for (const key of Object.keys(this.statePartMap)) {
|
||||
const part = this.statePartMap[key as StatePartNameType];
|
||||
if (part) {
|
||||
part.dispose();
|
||||
}
|
||||
}
|
||||
this.statePartMap = {} as any;
|
||||
this.pendingStatePartCreation.clear();
|
||||
this.pendingNotifications.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows getting and initializing a new statepart
|
||||
*/
|
||||
@@ -107,7 +126,7 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
}
|
||||
}
|
||||
|
||||
const creationPromise = this.createStatePart<PayloadType>(statePartNameArg, initialArg, initMode);
|
||||
const creationPromise = this.createStatePart<PayloadType>(statePartNameArg, initialArg!, initMode);
|
||||
this.pendingStatePartCreation.set(statePartNameArg, creationPromise);
|
||||
|
||||
try {
|
||||
@@ -133,7 +152,7 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
dbName: 'smartstate',
|
||||
storeName: statePartName,
|
||||
}
|
||||
: null
|
||||
: undefined
|
||||
);
|
||||
newState.smartstateRef = this;
|
||||
await newState.init();
|
||||
|
||||
@@ -18,8 +18,8 @@ export interface IActionDef<TStateType, TActionPayloadType> {
|
||||
*/
|
||||
export class StateAction<TStateType, TActionPayloadType> {
|
||||
constructor(
|
||||
public statePartRef: StatePart<any, any>,
|
||||
public actionDef: IActionDef<TStateType, TActionPayloadType>
|
||||
public readonly statePartRef: StatePart<any, any>,
|
||||
public readonly actionDef: IActionDef<TStateType, TActionPayloadType>
|
||||
) {}
|
||||
|
||||
public trigger(payload: TActionPayloadType): Promise<TStateType> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { Observable, shareReplay, takeUntil } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, shareReplay, takeUntil, distinctUntilChanged, interval } from 'rxjs';
|
||||
import { StateAction, type IActionDef, type IActionContext } from './smartstate.classes.stateaction.js';
|
||||
import type { Smartstate } from './smartstate.classes.smartstate.js';
|
||||
import { StateProcess, type IProcessOptions, type IScheduledActionOptions } from './smartstate.classes.stateprocess.js';
|
||||
|
||||
export type TMiddleware<TPayload> = (
|
||||
newState: TPayload,
|
||||
@@ -31,19 +32,23 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
private static readonly MAX_NESTED_DISPATCH_DEPTH = 10;
|
||||
|
||||
public name: TStatePartName;
|
||||
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
|
||||
public stateStore: TStatePayload | undefined;
|
||||
private state = new BehaviorSubject<TStatePayload | undefined>(undefined);
|
||||
private stateStore: TStatePayload | undefined;
|
||||
public smartstateRef?: Smartstate<any>;
|
||||
private disposed = false;
|
||||
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
|
||||
|
||||
private mutationQueue: Promise<any> = Promise.resolve();
|
||||
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private webStoreOptions: plugins.webstore.IWebStoreOptions;
|
||||
private webStoreOptions: plugins.webstore.IWebStoreOptions | undefined;
|
||||
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null;
|
||||
|
||||
private middlewares: TMiddleware<TStatePayload>[] = [];
|
||||
|
||||
// Process tracking
|
||||
private processes: StateProcess<TStatePartName, TStatePayload, any>[] = [];
|
||||
|
||||
// Selector memoization
|
||||
private selectorCache = new WeakMap<Function, plugins.smartrx.rxjs.Observable<any>>();
|
||||
private defaultSelectObservable: plugins.smartrx.rxjs.Observable<TStatePayload> | null = null;
|
||||
@@ -97,6 +102,9 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
* sets the stateStore to the new state (serialized via mutation queue)
|
||||
*/
|
||||
public async setState(newStateArg: TStatePayload): Promise<TStatePayload> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
return this.mutationQueue = this.mutationQueue.then(
|
||||
() => this.applyState(newStateArg),
|
||||
() => this.applyState(newStateArg),
|
||||
@@ -212,22 +220,24 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
}
|
||||
|
||||
const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
|
||||
const SELECTOR_ERROR: unique symbol = Symbol('selector-error');
|
||||
|
||||
let mapped = this.state.pipe(
|
||||
plugins.smartrx.rxjs.ops.startWith(this.getState()),
|
||||
plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined),
|
||||
plugins.smartrx.rxjs.ops.map((stateArg) => {
|
||||
try {
|
||||
return effectiveSelectorFn(stateArg);
|
||||
} catch (e) {
|
||||
console.error(`Selector error in state part '${this.name}':`, e);
|
||||
return undefined;
|
||||
return SELECTOR_ERROR as any;
|
||||
}
|
||||
})
|
||||
}),
|
||||
plugins.smartrx.rxjs.ops.filter((v: any) => v !== SELECTOR_ERROR),
|
||||
distinctUntilChanged((a: any, b: any) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
);
|
||||
|
||||
if (hasSignal) {
|
||||
mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal)));
|
||||
mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal!)));
|
||||
return mapped;
|
||||
}
|
||||
|
||||
@@ -277,19 +287,16 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
* dispatches an action on the statepart level
|
||||
*/
|
||||
public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
await this.cumulativeDeferred.promise;
|
||||
return this.mutationQueue = this.mutationQueue.then(
|
||||
async () => {
|
||||
const context = this.createActionContext(0);
|
||||
const newState = await stateAction.actionDef(this, actionPayload, context);
|
||||
return this.applyState(newState);
|
||||
},
|
||||
async () => {
|
||||
const context = this.createActionContext(0);
|
||||
const newState = await stateAction.actionDef(this, actionPayload, context);
|
||||
return this.applyState(newState);
|
||||
},
|
||||
);
|
||||
const execute = async () => {
|
||||
const context = this.createActionContext(0);
|
||||
const newState = await stateAction.actionDef(this, actionPayload, context);
|
||||
return this.applyState(newState);
|
||||
};
|
||||
return this.mutationQueue = this.mutationQueue.then(execute, execute);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,11 +381,68 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
await this.setState(await resultPromise);
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a managed, pausable process that ties an observable producer to state updates
|
||||
*/
|
||||
public createProcess<TProducerValue>(
|
||||
options: IProcessOptions<TStatePayload, TProducerValue>
|
||||
): StateProcess<TStatePartName, TStatePayload, TProducerValue> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
const process = new StateProcess<TStatePartName, TStatePayload, TProducerValue>(this, options);
|
||||
this.processes.push(process);
|
||||
if (options.autoStart) {
|
||||
process.start();
|
||||
}
|
||||
return process;
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a process that dispatches an action on a recurring interval
|
||||
*/
|
||||
public createScheduledAction<TActionPayload>(
|
||||
options: IScheduledActionOptions<TStatePayload, TActionPayload>
|
||||
): StateProcess<TStatePartName, TStatePayload, number> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
const process = new StateProcess<TStatePartName, TStatePayload, number>(this, {
|
||||
producer: () => interval(options.intervalMs),
|
||||
sideEffect: async () => {
|
||||
await options.action.trigger(options.payload);
|
||||
},
|
||||
autoPause: options.autoPause ?? false,
|
||||
});
|
||||
this.processes.push(process);
|
||||
process.start();
|
||||
return process;
|
||||
}
|
||||
|
||||
/** @internal — called by StateProcess.dispose() to remove itself */
|
||||
public _removeProcess(process: StateProcess<any, any, any>): void {
|
||||
const idx = this.processes.indexOf(process);
|
||||
if (idx !== -1) {
|
||||
this.processes.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* disposes the state part, completing the Subject and cleaning up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
|
||||
// Dispose all processes first
|
||||
for (const process of [...this.processes]) {
|
||||
process.dispose();
|
||||
}
|
||||
this.processes.length = 0;
|
||||
|
||||
this.state.complete();
|
||||
this.mutationQueue = Promise.resolve() as any;
|
||||
|
||||
if (this.pendingCumulativeNotification) {
|
||||
clearTimeout(this.pendingCumulativeNotification);
|
||||
this.pendingCumulativeNotification = null;
|
||||
@@ -388,5 +452,6 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
this.defaultSelectObservable = null;
|
||||
this.webStore = null;
|
||||
this.smartstateRef = undefined;
|
||||
this.stateStore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
177
ts/smartstate.classes.stateprocess.ts
Normal file
177
ts/smartstate.classes.stateprocess.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
|
||||
import type { StatePart } from './smartstate.classes.statepart.js';
|
||||
import type { StateAction } from './smartstate.classes.stateaction.js';
|
||||
|
||||
export type TProcessStatus = 'idle' | 'running' | 'paused' | 'disposed';
|
||||
export type TAutoPause = 'visibility' | Observable<boolean> | false;
|
||||
|
||||
export interface IProcessOptions<TStatePayload, TProducerValue> {
|
||||
producer: () => Observable<TProducerValue>;
|
||||
reducer: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
autoPause?: TAutoPause;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
export interface IScheduledActionOptions<TStatePayload, TActionPayload> {
|
||||
action: StateAction<TStatePayload, TActionPayload>;
|
||||
payload: TActionPayload;
|
||||
intervalMs: number;
|
||||
autoPause?: TAutoPause;
|
||||
}
|
||||
|
||||
/**
|
||||
* creates an Observable<boolean> from the Page Visibility API.
|
||||
* emits true when the page is visible, false when hidden.
|
||||
* in Node.js (no document), returns an always-true observable.
|
||||
*/
|
||||
function createVisibilityObservable(): Observable<boolean> {
|
||||
if (typeof document === 'undefined') {
|
||||
return of(true);
|
||||
}
|
||||
return new Observable<boolean>((subscriber) => {
|
||||
subscriber.next(!document.hidden);
|
||||
const handler = () => subscriber.next(!document.hidden);
|
||||
document.addEventListener('visibilitychange', handler);
|
||||
return () => document.removeEventListener('visibilitychange', handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* a managed, pausable process that ties an observable producer to state updates.
|
||||
* supports lifecycle management (start/pause/resume/dispose) and auto-pause signals.
|
||||
*/
|
||||
export class StateProcess<TStatePartName, TStatePayload, TProducerValue> {
|
||||
private readonly statePartRef: StatePart<TStatePartName, TStatePayload>;
|
||||
private readonly producerFn: () => Observable<TProducerValue>;
|
||||
private readonly reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
private readonly sideEffect?: (value: TProducerValue) => Promise<void> | void;
|
||||
private readonly autoPauseOption: TAutoPause;
|
||||
|
||||
private statusSubject = new BehaviorSubject<TProcessStatus>('idle');
|
||||
private producerSubscription: Subscription | null = null;
|
||||
private autoPauseSubscription: Subscription | null = null;
|
||||
private processingQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
statePartRef: StatePart<TStatePartName, TStatePayload>,
|
||||
options: {
|
||||
producer: () => Observable<TProducerValue>;
|
||||
reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
sideEffect?: (value: TProducerValue) => Promise<void> | void;
|
||||
autoPause?: TAutoPause;
|
||||
}
|
||||
) {
|
||||
this.statePartRef = statePartRef;
|
||||
this.producerFn = options.producer;
|
||||
this.reducer = options.reducer;
|
||||
this.sideEffect = options.sideEffect;
|
||||
this.autoPauseOption = options.autoPause ?? false;
|
||||
}
|
||||
|
||||
public get status(): TProcessStatus {
|
||||
return this.statusSubject.getValue();
|
||||
}
|
||||
|
||||
public get status$(): Observable<TProcessStatus> {
|
||||
return this.statusSubject.asObservable();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot start a disposed process');
|
||||
}
|
||||
if (this.status === 'running') return;
|
||||
this.statusSubject.next('running');
|
||||
this.subscribeProducer();
|
||||
this.setupAutoPause();
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot pause a disposed process');
|
||||
}
|
||||
if (this.status !== 'running') return;
|
||||
this.statusSubject.next('paused');
|
||||
this.unsubscribeProducer();
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot resume a disposed process');
|
||||
}
|
||||
if (this.status !== 'paused') return;
|
||||
this.statusSubject.next('running');
|
||||
this.subscribeProducer();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.status === 'disposed') return;
|
||||
this.unsubscribeProducer();
|
||||
this.teardownAutoPause();
|
||||
this.statusSubject.next('disposed');
|
||||
this.statusSubject.complete();
|
||||
this.statePartRef._removeProcess(this);
|
||||
}
|
||||
|
||||
private subscribeProducer(): void {
|
||||
this.unsubscribeProducer();
|
||||
const source = this.producerFn();
|
||||
this.producerSubscription = source.subscribe({
|
||||
next: (value) => {
|
||||
// Queue value processing to ensure each reads fresh state after the previous completes
|
||||
this.processingQueue = this.processingQueue.then(async () => {
|
||||
try {
|
||||
if (this.sideEffect) {
|
||||
await this.sideEffect(value);
|
||||
} else if (this.reducer) {
|
||||
const currentState = this.statePartRef.getState();
|
||||
if (currentState !== undefined) {
|
||||
await this.statePartRef.setState(this.reducer(currentState, value));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('StateProcess value handling error:', err);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('StateProcess producer error:', err);
|
||||
if (this.status === 'running') {
|
||||
this.statusSubject.next('paused');
|
||||
this.unsubscribeProducer();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private unsubscribeProducer(): void {
|
||||
if (this.producerSubscription) {
|
||||
this.producerSubscription.unsubscribe();
|
||||
this.producerSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setupAutoPause(): void {
|
||||
this.teardownAutoPause();
|
||||
if (!this.autoPauseOption) return;
|
||||
|
||||
const signal$ = this.autoPauseOption === 'visibility'
|
||||
? createVisibilityObservable()
|
||||
: this.autoPauseOption;
|
||||
|
||||
this.autoPauseSubscription = signal$.subscribe((active) => {
|
||||
if (!active && this.status === 'running') {
|
||||
this.pause();
|
||||
} else if (active && this.status === 'paused') {
|
||||
this.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private teardownAutoPause(): void {
|
||||
if (this.autoPauseSubscription) {
|
||||
this.autoPauseSubscription.unsubscribe();
|
||||
this.autoPauseSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user