fix(smartstate): prevent duplicate statepart creation and fix persistence/notification race conditions
This commit is contained in:
12
changelog.md
12
changelog.md
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-02 - 2.0.29 - fix(smartstate)
|
||||
prevent duplicate statepart creation and fix persistence/notification race conditions
|
||||
|
||||
- Add pendingStatePartCreation map to deduplicate concurrent createStatePart calls
|
||||
- Adjust init handling so 'force' falls through to creation and concurrent creations are serialized
|
||||
- Merge persisted state with initial payload in 'persistent' initMode, with persisted values taking precedence
|
||||
- Persist to WebStore before updating in-memory state to ensure atomicity
|
||||
- Debounce cumulative notifications via pendingCumulativeNotification to avoid duplicate notifications
|
||||
- Log selector errors instead of silently swallowing exceptions
|
||||
- Add optional timeout to waitUntilPresent and ensure subscriptions and timeouts are cleaned up to avoid indefinite waits
|
||||
- Await setState when performing chained state updates to ensure ordering and avoid race conditions
|
||||
|
||||
## 2026-02-02 - 2.0.28 - fix(deps)
|
||||
bump devDependencies and dependencies, add tsbundle build config, update docs, and reorganize tests
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartstate',
|
||||
version: '2.0.28',
|
||||
version: '2.0.29',
|
||||
description: 'A package for handling and managing state in applications.'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent';
|
||||
export class Smartstate<StatePartNameType extends string> {
|
||||
public statePartMap: { [key in StatePartNameType]?: StatePart<StatePartNameType, any> } = {};
|
||||
|
||||
private pendingStatePartCreation: Map<string, Promise<StatePart<StatePartNameType, any>>> = new Map();
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,12 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
initialArg?: PayloadType,
|
||||
initMode: TInitMode = 'soft'
|
||||
): Promise<StatePart<StatePartNameType, PayloadType>> {
|
||||
// Return pending creation if one exists to prevent duplicate state parts
|
||||
const pending = this.pendingStatePartCreation.get(statePartNameArg);
|
||||
if (pending) {
|
||||
return pending as Promise<StatePart<StatePartNameType, PayloadType>>;
|
||||
}
|
||||
|
||||
const existingStatePart = this.statePartMap[statePartNameArg];
|
||||
|
||||
if (existingStatePart) {
|
||||
@@ -36,7 +44,7 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
);
|
||||
case 'force':
|
||||
// Force mode: create new state part
|
||||
return this.createStatePart<PayloadType>(statePartNameArg, initialArg, initMode);
|
||||
break; // Fall through to creation
|
||||
case 'soft':
|
||||
case 'persistent':
|
||||
default:
|
||||
@@ -50,7 +58,16 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
`State part '${statePartNameArg}' does not exist and no initial state provided`
|
||||
);
|
||||
}
|
||||
return this.createStatePart<PayloadType>(statePartNameArg, initialArg, initMode);
|
||||
}
|
||||
|
||||
const creationPromise = this.createStatePart<PayloadType>(statePartNameArg, initialArg, initMode);
|
||||
this.pendingStatePartCreation.set(statePartNameArg, creationPromise);
|
||||
|
||||
try {
|
||||
const result = await creationPromise;
|
||||
return result;
|
||||
} finally {
|
||||
this.pendingStatePartCreation.delete(statePartNameArg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +93,18 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
);
|
||||
await newState.init();
|
||||
const currentState = newState.getState();
|
||||
await newState.setState({
|
||||
...currentState,
|
||||
...initialPayloadArg,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
public stateStore: TStatePayload | undefined;
|
||||
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
|
||||
|
||||
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private webStoreOptions: plugins.webstore.IWebStoreOptions;
|
||||
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null; // Add WebStore instance
|
||||
|
||||
@@ -51,13 +53,15 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
throw new Error(`Invalid state structure for state part '${this.name}'`);
|
||||
}
|
||||
|
||||
this.stateStore = newStateArg;
|
||||
await this.notifyChange();
|
||||
|
||||
// Save state to WebStore if initialized
|
||||
// Save to WebStore first to ensure atomicity - if save fails, memory state remains unchanged
|
||||
if (this.webStore) {
|
||||
await this.webStore.set(String(this.name), newStateArg);
|
||||
}
|
||||
|
||||
// Update in-memory state after successful persistence
|
||||
this.stateStore = newStateArg;
|
||||
await this.notifyChange();
|
||||
|
||||
return this.stateStore;
|
||||
}
|
||||
|
||||
@@ -98,8 +102,13 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
* creates a cumulative notification by adding a change notification at the end of the call stack;
|
||||
*/
|
||||
public notifyChangeCumulative() {
|
||||
// TODO: check viability
|
||||
setTimeout(async () => {
|
||||
// Debounce: clear any pending notification
|
||||
if (this.pendingCumulativeNotification) {
|
||||
clearTimeout(this.pendingCumulativeNotification);
|
||||
}
|
||||
|
||||
this.pendingCumulativeNotification = setTimeout(async () => {
|
||||
this.pendingCumulativeNotification = null;
|
||||
if (this.stateStore) {
|
||||
await this.notifyChange();
|
||||
}
|
||||
@@ -122,7 +131,8 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
try {
|
||||
return selectorFn(stateArg);
|
||||
} catch (e) {
|
||||
// Nothing here
|
||||
console.error(`Selector error in state part '${this.name}':`, e);
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -151,20 +161,41 @@ 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
|
||||
*/
|
||||
public async waitUntilPresent<T = TStatePayload>(
|
||||
selectorFn?: (state: TStatePayload) => T
|
||||
selectorFn?: (state: TStatePayload) => T,
|
||||
timeoutMs?: number
|
||||
): Promise<T> {
|
||||
const done = plugins.smartpromise.defer<T>();
|
||||
const selectedObservable = this.select(selectorFn);
|
||||
const subscription = selectedObservable.subscribe(async (value) => {
|
||||
if (value) {
|
||||
let resolved = false;
|
||||
|
||||
const subscription = selectedObservable.subscribe((value) => {
|
||||
if (value && !resolved) {
|
||||
resolved = true;
|
||||
done.resolve(value);
|
||||
}
|
||||
});
|
||||
const result = await done.promise;
|
||||
subscription.unsubscribe();
|
||||
return result;
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
if (timeoutMs) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
subscription.unsubscribe();
|
||||
done.reject(new Error(`waitUntilPresent timed out after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await done.promise;
|
||||
return result;
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +206,6 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
) {
|
||||
const resultPromise = funcArg(this);
|
||||
this.cumulativeDeferred.addPromise(resultPromise);
|
||||
this.setState(await resultPromise);
|
||||
await this.setState(await resultPromise);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user