fix(core): serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartstate',
|
||||
version: '2.1.0',
|
||||
version: '2.1.1',
|
||||
description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
|
||||
// Batch support
|
||||
private batchDepth = 0;
|
||||
private isFlushing = false;
|
||||
private pendingNotifications = new Set<StatePart<any, any>>();
|
||||
|
||||
constructor() {}
|
||||
@@ -41,11 +42,18 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
await updateFn();
|
||||
} finally {
|
||||
this.batchDepth--;
|
||||
if (this.batchDepth === 0) {
|
||||
const pending = [...this.pendingNotifications];
|
||||
this.pendingNotifications.clear();
|
||||
for (const sp of pending) {
|
||||
await sp.notifyChange();
|
||||
if (this.batchDepth === 0 && !this.isFlushing) {
|
||||
this.isFlushing = true;
|
||||
try {
|
||||
while (this.pendingNotifications.size > 0) {
|
||||
const pending = [...this.pendingNotifications];
|
||||
this.pendingNotifications.clear();
|
||||
for (const sp of pending) {
|
||||
await sp.notifyChange();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +92,7 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
`State part '${statePartNameArg}' already exists, but initMode is 'mandatory'`
|
||||
);
|
||||
case 'force':
|
||||
existingStatePart.dispose();
|
||||
break;
|
||||
case 'soft':
|
||||
case 'persistent':
|
||||
@@ -91,7 +100,7 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
return existingStatePart as StatePart<StatePartNameType, PayloadType>;
|
||||
}
|
||||
} else {
|
||||
if (!initialArg) {
|
||||
if (initialArg === undefined) {
|
||||
throw new Error(
|
||||
`State part '${statePartNameArg}' does not exist and no initial state provided`
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { StatePart } from './smartstate.classes.statepart.js';
|
||||
|
||||
export interface IActionDef<TStateType, TActionPayloadType> {
|
||||
|
||||
@@ -34,8 +34,8 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
public smartstateRef?: Smartstate<any>;
|
||||
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
|
||||
|
||||
private mutationQueue: Promise<any> = Promise.resolve();
|
||||
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingBatchNotification = false;
|
||||
|
||||
private webStoreOptions: plugins.webstore.IWebStoreOptions;
|
||||
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null;
|
||||
@@ -92,9 +92,19 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
}
|
||||
|
||||
/**
|
||||
* sets the stateStore to the new state
|
||||
* sets the stateStore to the new state (serialized via mutation queue)
|
||||
*/
|
||||
public async setState(newStateArg: TStatePayload) {
|
||||
public async setState(newStateArg: TStatePayload): Promise<TStatePayload> {
|
||||
return this.mutationQueue = this.mutationQueue.then(
|
||||
() => this.applyState(newStateArg),
|
||||
() => this.applyState(newStateArg),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* applies the state change (middleware → validate → persist → notify)
|
||||
*/
|
||||
private async applyState(newStateArg: TStatePayload): Promise<TStatePayload> {
|
||||
// Run middleware chain
|
||||
let processedState = newStateArg;
|
||||
for (const mw of this.middlewares) {
|
||||
@@ -129,13 +139,13 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
* notifies of a change on the state
|
||||
*/
|
||||
public async notifyChange() {
|
||||
if (!this.stateStore) {
|
||||
const snapshot = this.stateStore;
|
||||
if (snapshot === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If inside a batch, defer the notification
|
||||
if (this.smartstateRef?.isBatching) {
|
||||
this.pendingBatchNotification = true;
|
||||
this.smartstateRef.registerPendingNotification(this);
|
||||
return;
|
||||
}
|
||||
@@ -143,16 +153,19 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
const createStateHash = async (stateArg: any) => {
|
||||
return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
|
||||
};
|
||||
const currentHash = await createStateHash(this.stateStore);
|
||||
if (
|
||||
this.lastStateNotificationPayloadHash &&
|
||||
currentHash === this.lastStateNotificationPayloadHash
|
||||
) {
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
const currentHash = await createStateHash(snapshot);
|
||||
if (
|
||||
this.lastStateNotificationPayloadHash &&
|
||||
currentHash === this.lastStateNotificationPayloadHash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.lastStateNotificationPayloadHash = currentHash;
|
||||
} catch (err) {
|
||||
console.error(`State hash computation failed for '${this.name}':`, err);
|
||||
}
|
||||
this.state.next(this.stateStore);
|
||||
this.state.next(snapshot);
|
||||
}
|
||||
private lastStateNotificationPayloadHash: any;
|
||||
|
||||
@@ -164,10 +177,12 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
clearTimeout(this.pendingCumulativeNotification);
|
||||
}
|
||||
|
||||
this.pendingCumulativeNotification = setTimeout(async () => {
|
||||
this.pendingCumulativeNotification = setTimeout(() => {
|
||||
this.pendingCumulativeNotification = null;
|
||||
if (this.stateStore) {
|
||||
await this.notifyChange();
|
||||
if (this.stateStore !== undefined) {
|
||||
this.notifyChange().catch((err) => {
|
||||
console.error(`notifyChangeCumulative failed for '${this.name}':`, err);
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
@@ -239,9 +254,16 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
*/
|
||||
public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
|
||||
await this.cumulativeDeferred.promise;
|
||||
const newState = await stateAction.actionDef(this, actionPayload);
|
||||
await this.setState(newState);
|
||||
return this.getState();
|
||||
return this.mutationQueue = this.mutationQueue.then(
|
||||
async () => {
|
||||
const newState = await stateAction.actionDef(this, actionPayload);
|
||||
return this.applyState(newState);
|
||||
},
|
||||
async () => {
|
||||
const newState = await stateAction.actionDef(this, actionPayload);
|
||||
return this.applyState(newState);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,7 +294,7 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
}
|
||||
|
||||
const subscription = selectedObservable.subscribe((value) => {
|
||||
if (value && !resolved) {
|
||||
if (value !== undefined && value !== null && !resolved) {
|
||||
resolved = true;
|
||||
done.resolve(value);
|
||||
}
|
||||
@@ -325,4 +347,20 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
this.cumulativeDeferred.addPromise(resultPromise);
|
||||
await this.setState(await resultPromise);
|
||||
}
|
||||
|
||||
/**
|
||||
* disposes the state part, completing the Subject and cleaning up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.state.complete();
|
||||
if (this.pendingCumulativeNotification) {
|
||||
clearTimeout(this.pendingCumulativeNotification);
|
||||
this.pendingCumulativeNotification = null;
|
||||
}
|
||||
this.middlewares.length = 0;
|
||||
this.selectorCache = new WeakMap();
|
||||
this.defaultSelectObservable = null;
|
||||
this.webStore = null;
|
||||
this.smartstateRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user