feat(smartstate): Add middleware, computed, batching, selector memoization, AbortSignal support, and Web Component Context Protocol provider

This commit is contained in:
2026-02-27 11:40:07 +00:00
parent 39aa63bdb3
commit 575477df09
13 changed files with 1091 additions and 344 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartstate',
version: '2.0.31',
description: 'A package for handling and managing state in applications.'
version: '2.1.0',
description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
}

View File

@@ -1,3 +1,5 @@
export * from './smartstate.classes.smartstate.js';
export * from './smartstate.classes.statepart.js';
export * from './smartstate.classes.stateaction.js';
export * from './smartstate.classes.computed.js';
export * from './smartstate.contextprovider.js';

View File

@@ -0,0 +1,16 @@
import * as plugins from './smartstate.plugins.js';
import { combineLatest, map } from 'rxjs';
import type { StatePart } from './smartstate.classes.statepart.js';
/**
* creates a computed observable derived from multiple state parts.
* the observable is lazy — it only subscribes to sources when subscribed to.
*/
export function computed<TResult>(
sources: StatePart<any, any>[],
computeFn: (...states: any[]) => TResult,
): plugins.smartrx.rxjs.Observable<TResult> {
return combineLatest(sources.map((sp) => sp.select())).pipe(
map((states) => computeFn(...states)),
) as plugins.smartrx.rxjs.Observable<TResult>;
}

View File

@@ -1,5 +1,6 @@
import * as plugins from './smartstate.plugins.js';
import { StatePart } from './smartstate.classes.statepart.js';
import { computed } from './smartstate.classes.computed.js';
export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent';
@@ -11,17 +12,57 @@ export class Smartstate<StatePartNameType extends string> {
private pendingStatePartCreation: Map<string, Promise<StatePart<StatePartNameType, any>>> = new Map();
// Batch support
private batchDepth = 0;
private pendingNotifications = new Set<StatePart<any, any>>();
constructor() {}
/**
* whether state changes are currently being batched
*/
public get isBatching(): boolean {
return this.batchDepth > 0;
}
/**
* registers a state part for deferred notification during a batch
*/
public registerPendingNotification(statePart: StatePart<any, any>): void {
this.pendingNotifications.add(statePart);
}
/**
* batches multiple state updates so subscribers are only notified once all updates complete
*/
public async batch(updateFn: () => Promise<void> | void): Promise<void> {
this.batchDepth++;
try {
await updateFn();
} finally {
this.batchDepth--;
if (this.batchDepth === 0) {
const pending = [...this.pendingNotifications];
this.pendingNotifications.clear();
for (const sp of pending) {
await sp.notifyChange();
}
}
}
}
/**
* creates a computed observable derived from multiple state parts
*/
public computed<TResult>(
sources: StatePart<StatePartNameType, any>[],
computeFn: (...states: any[]) => TResult,
): plugins.smartrx.rxjs.Observable<TResult> {
return computed(sources, computeFn);
}
/**
* Allows getting and initializing a new statepart
* initMode === 'soft' (default) - returns existing statepart if exists, creates new if not
* initMode === 'mandatory' - requires statepart to not exist, fails if it does
* initMode === 'force' - always creates new statepart, overwriting any existing
* initMode === 'persistent' - like 'soft' but with webstore persistence
* @param statePartNameArg
* @param initialArg
* @param initMode
*/
public async getStatePart<PayloadType>(
statePartNameArg: StatePartNameType,
@@ -43,16 +84,13 @@ export class Smartstate<StatePartNameType extends string> {
`State part '${statePartNameArg}' already exists, but initMode is 'mandatory'`
);
case 'force':
// Force mode: create new state part
break; // Fall through to creation
break;
case 'soft':
case 'persistent':
default:
// Return existing state part
return existingStatePart as StatePart<StatePartNameType, PayloadType>;
}
} else {
// State part doesn't exist
if (!initialArg) {
throw new Error(
`State part '${statePartNameArg}' does not exist and no initial state provided`
@@ -73,9 +111,6 @@ export class Smartstate<StatePartNameType extends string> {
/**
* Creates a statepart
* @param statePartName
* @param initialPayloadArg
* @param initMode
*/
private async createStatePart<PayloadType>(
statePartName: StatePartNameType,
@@ -91,21 +126,20 @@ export class Smartstate<StatePartNameType extends string> {
}
: null
);
newState.smartstateRef = this;
await newState.init();
const currentState = newState.getState();
if (initMode === 'persistent' && currentState !== undefined) {
// Persisted state exists - merge with defaults, persisted values take precedence
await newState.setState({
...initialPayloadArg,
...currentState,
});
} else {
// No persisted state or non-persistent mode
await newState.setState(initialPayloadArg);
}
this.statePartMap[statePartName] = newState;
return newState;
}
}
}

View File

@@ -1,21 +1,54 @@
import * as plugins from './smartstate.plugins.js';
import { Observable, shareReplay, takeUntil } from 'rxjs';
import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js';
import type { Smartstate } from './smartstate.classes.smartstate.js';
export type TMiddleware<TPayload> = (
newState: TPayload,
oldState: TPayload | undefined,
) => TPayload | Promise<TPayload>;
/**
* creates an Observable that emits once when the given AbortSignal fires
*/
function fromAbortSignal(signal: AbortSignal): Observable<void> {
return new Observable<void>((subscriber) => {
if (signal.aborted) {
subscriber.next();
subscriber.complete();
return;
}
const handler = () => {
subscriber.next();
subscriber.complete();
};
signal.addEventListener('abort', handler);
return () => signal.removeEventListener('abort', handler);
});
}
export class StatePart<TStatePartName, TStatePayload> {
public name: TStatePartName;
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
public stateStore: TStatePayload | undefined;
public smartstateRef?: Smartstate<any>;
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
private pendingBatchNotification = false;
private webStoreOptions: plugins.webstore.IWebStoreOptions;
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null; // Add WebStore instance
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null;
private middlewares: TMiddleware<TStatePayload>[] = [];
// Selector memoization
private selectorCache = new WeakMap<Function, plugins.smartrx.rxjs.Observable<any>>();
private defaultSelectObservable: plugins.smartrx.rxjs.Observable<TStatePayload> | null = null;
constructor(nameArg: TStatePartName, webStoreOptionsArg?: plugins.webstore.IWebStoreOptions) {
this.name = nameArg;
// Initialize WebStore if webStoreOptions are provided
if (webStoreOptionsArg) {
this.webStoreOptions = webStoreOptionsArg;
}
@@ -43,23 +76,43 @@ export class StatePart<TStatePartName, TStatePayload> {
return this.stateStore;
}
/**
* adds a middleware that intercepts setState calls.
* middleware can transform the state or throw to reject it.
* returns a removal function.
*/
public addMiddleware(middleware: TMiddleware<TStatePayload>): () => void {
this.middlewares.push(middleware);
return () => {
const idx = this.middlewares.indexOf(middleware);
if (idx !== -1) {
this.middlewares.splice(idx, 1);
}
};
}
/**
* sets the stateStore to the new state
* @param newStateArg
*/
public async setState(newStateArg: TStatePayload) {
// Run middleware chain
let processedState = newStateArg;
for (const mw of this.middlewares) {
processedState = await mw(processedState, this.stateStore);
}
// Validate state structure
if (!this.validateState(newStateArg)) {
if (!this.validateState(processedState)) {
throw new Error(`Invalid state structure for state part '${this.name}'`);
}
// Save to WebStore first to ensure atomicity - if save fails, memory state remains unchanged
// Save to WebStore first to ensure atomicity
if (this.webStore) {
await this.webStore.set(String(this.name), newStateArg);
await this.webStore.set(String(this.name), processedState);
}
// Update in-memory state after successful persistence
this.stateStore = newStateArg;
this.stateStore = processedState;
await this.notifyChange();
return this.stateStore;
@@ -67,11 +120,8 @@ export class StatePart<TStatePartName, TStatePayload> {
/**
* Validates state structure - can be overridden for custom validation
* @param stateArg
*/
protected validateState(stateArg: any): stateArg is TStatePayload {
// Basic validation - ensure state is not null/undefined
// Subclasses can override for more specific validation
return stateArg !== null && stateArg !== undefined;
}
@@ -82,6 +132,14 @@ export class StatePart<TStatePartName, TStatePayload> {
if (!this.stateStore) {
return;
}
// If inside a batch, defer the notification
if (this.smartstateRef?.isBatching) {
this.pendingBatchNotification = true;
this.smartstateRef.registerPendingNotification(this);
return;
}
const createStateHash = async (stateArg: any) => {
return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
};
@@ -99,10 +157,9 @@ export class StatePart<TStatePartName, TStatePayload> {
private lastStateNotificationPayloadHash: any;
/**
* creates a cumulative notification by adding a change notification at the end of the call stack;
* creates a cumulative notification by adding a change notification at the end of the call stack
*/
public notifyChangeCumulative() {
// Debounce: clear any pending notification
if (this.pendingCumulativeNotification) {
clearTimeout(this.pendingCumulativeNotification);
}
@@ -116,27 +173,56 @@ export class StatePart<TStatePartName, TStatePayload> {
}
/**
* selects a state or a substate
* selects a state or a substate.
* supports an optional AbortSignal for automatic unsubscription.
* memoizes observables by selector function reference.
*/
public select<T = TStatePayload>(
selectorFn?: (state: TStatePayload) => T
selectorFn?: (state: TStatePayload) => T,
options?: { signal?: AbortSignal }
): plugins.smartrx.rxjs.Observable<T> {
if (!selectorFn) {
selectorFn = (state: TStatePayload) => <T>(<any>state);
const hasSignal = options?.signal != null;
// Check memoization cache (only for non-signal selects)
if (!hasSignal) {
if (!selectorFn) {
if (this.defaultSelectObservable) {
return this.defaultSelectObservable as unknown as plugins.smartrx.rxjs.Observable<T>;
}
} else if (this.selectorCache.has(selectorFn)) {
return this.selectorCache.get(selectorFn)!;
}
}
const mapped = this.state.pipe(
const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
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 selectorFn(stateArg);
return effectiveSelectorFn(stateArg);
} catch (e) {
console.error(`Selector error in state part '${this.name}':`, e);
return undefined;
}
})
);
return mapped;
if (hasSignal) {
mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal)));
return mapped;
}
// Apply shareReplay for caching and store in memo cache
const shared = mapped.pipe(shareReplay({ bufferSize: 1, refCount: true }));
if (!selectorFn) {
this.defaultSelectObservable = shared as unknown as plugins.smartrx.rxjs.Observable<TStatePayload>;
} else {
this.selectorCache.set(selectorFn, shared);
}
return shared;
}
/**
@@ -159,18 +245,32 @@ export class StatePart<TStatePartName, TStatePayload> {
}
/**
* waits until a certain part of the state becomes available
* @param selectorFn
* @param timeoutMs - optional timeout in milliseconds to prevent indefinite waiting
* waits until a certain part of the state becomes available.
* supports optional timeout and AbortSignal.
*/
public async waitUntilPresent<T = TStatePayload>(
selectorFn?: (state: TStatePayload) => T,
timeoutMs?: number
optionsOrTimeout?: number | { timeoutMs?: number; signal?: AbortSignal }
): Promise<T> {
// Parse backward-compatible args
let timeoutMs: number | undefined;
let signal: AbortSignal | undefined;
if (typeof optionsOrTimeout === 'number') {
timeoutMs = optionsOrTimeout;
} else if (optionsOrTimeout) {
timeoutMs = optionsOrTimeout.timeoutMs;
signal = optionsOrTimeout.signal;
}
const done = plugins.smartpromise.defer<T>();
const selectedObservable = this.select(selectorFn);
let resolved = false;
// Check if already aborted
if (signal?.aborted) {
throw new Error('Aborted');
}
const subscription = selectedObservable.subscribe((value) => {
if (value && !resolved) {
resolved = true;
@@ -189,12 +289,29 @@ export class StatePart<TStatePartName, TStatePayload> {
}, timeoutMs);
}
// Handle abort signal
const abortHandler = signal ? () => {
if (!resolved) {
resolved = true;
subscription.unsubscribe();
if (timeoutId) clearTimeout(timeoutId);
done.reject(new Error('Aborted'));
}
} : undefined;
if (signal && abortHandler) {
signal.addEventListener('abort', abortHandler);
}
try {
const result = await done.promise;
return result;
} finally {
subscription.unsubscribe();
if (timeoutId) clearTimeout(timeoutId);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
}
}

View File

@@ -0,0 +1,61 @@
import type { StatePart } from './smartstate.classes.statepart.js';
export interface IContextProviderOptions<TPayload> {
/** the context key (compared by strict equality) */
context: unknown;
/** the state part to provide */
statePart: StatePart<any, TPayload>;
/** optional selector to provide a derived value instead of the full state */
selectorFn?: (state: TPayload) => any;
}
/**
* attaches a Context Protocol provider to an HTML element.
* listens for `context-request` events and responds with the state part's value.
* if subscribe=true, retains the callback and invokes it on every state change.
* returns a cleanup function that removes the listener and unsubscribes.
*/
export function attachContextProvider<TPayload>(
element: HTMLElement,
options: IContextProviderOptions<TPayload>,
): () => void {
const { context, statePart, selectorFn } = options;
const subscribers = new Set<(value: any, unsubscribe?: () => void) => void>();
const subscription = statePart.select(selectorFn).subscribe((value) => {
for (const cb of subscribers) {
cb(value);
}
});
const getValue = (): any => {
const state = statePart.getState();
if (state === undefined) return undefined;
return selectorFn ? selectorFn(state) : state;
};
const handler = (event: Event) => {
const e = event as CustomEvent;
const detail = e.detail;
if (!detail || detail.context !== context) return;
e.stopPropagation();
if (detail.subscribe) {
const cb = detail.callback;
subscribers.add(cb);
const unsubscribe = () => subscribers.delete(cb);
cb(getValue(), unsubscribe);
} else {
detail.callback(getValue());
}
};
element.addEventListener('context-request', handler);
return () => {
element.removeEventListener('context-request', handler);
subscription.unsubscribe();
subscribers.clear();
};
}