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
|
# 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)
|
## 2026-02-02 - 2.0.28 - fix(deps)
|
||||||
bump devDependencies and dependencies, add tsbundle build config, update docs, and reorganize tests
|
bump devDependencies and dependencies, add tsbundle build config, update docs, and reorganize tests
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartstate',
|
name: '@push.rocks/smartstate',
|
||||||
version: '2.0.28',
|
version: '2.0.29',
|
||||||
description: 'A package for handling and managing state in applications.'
|
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> {
|
export class Smartstate<StatePartNameType extends string> {
|
||||||
public statePartMap: { [key in StatePartNameType]?: StatePart<StatePartNameType, any> } = {};
|
public statePartMap: { [key in StatePartNameType]?: StatePart<StatePartNameType, any> } = {};
|
||||||
|
|
||||||
|
private pendingStatePartCreation: Map<string, Promise<StatePart<StatePartNameType, any>>> = new Map();
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +28,12 @@ export class Smartstate<StatePartNameType extends string> {
|
|||||||
initialArg?: PayloadType,
|
initialArg?: PayloadType,
|
||||||
initMode: TInitMode = 'soft'
|
initMode: TInitMode = 'soft'
|
||||||
): Promise<StatePart<StatePartNameType, PayloadType>> {
|
): 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];
|
const existingStatePart = this.statePartMap[statePartNameArg];
|
||||||
|
|
||||||
if (existingStatePart) {
|
if (existingStatePart) {
|
||||||
@@ -36,7 +44,7 @@ export class Smartstate<StatePartNameType extends string> {
|
|||||||
);
|
);
|
||||||
case 'force':
|
case 'force':
|
||||||
// Force mode: create new state part
|
// Force mode: create new state part
|
||||||
return this.createStatePart<PayloadType>(statePartNameArg, initialArg, initMode);
|
break; // Fall through to creation
|
||||||
case 'soft':
|
case 'soft':
|
||||||
case 'persistent':
|
case 'persistent':
|
||||||
default:
|
default:
|
||||||
@@ -50,7 +58,16 @@ export class Smartstate<StatePartNameType extends string> {
|
|||||||
`State part '${statePartNameArg}' does not exist and no initial state provided`
|
`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();
|
await newState.init();
|
||||||
const currentState = newState.getState();
|
const currentState = newState.getState();
|
||||||
|
|
||||||
|
if (initMode === 'persistent' && currentState !== undefined) {
|
||||||
|
// Persisted state exists - merge with defaults, persisted values take precedence
|
||||||
await newState.setState({
|
await newState.setState({
|
||||||
...currentState,
|
|
||||||
...initialPayloadArg,
|
...initialPayloadArg,
|
||||||
|
...currentState,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// No persisted state or non-persistent mode
|
||||||
|
await newState.setState(initialPayloadArg);
|
||||||
|
}
|
||||||
|
|
||||||
this.statePartMap[statePartName] = newState;
|
this.statePartMap[statePartName] = newState;
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export class StatePart<TStatePartName, TStatePayload> {
|
|||||||
public stateStore: TStatePayload | undefined;
|
public stateStore: TStatePayload | undefined;
|
||||||
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
|
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
|
||||||
|
|
||||||
|
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
private webStoreOptions: plugins.webstore.IWebStoreOptions;
|
private webStoreOptions: plugins.webstore.IWebStoreOptions;
|
||||||
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null; // Add WebStore instance
|
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}'`);
|
throw new Error(`Invalid state structure for state part '${this.name}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stateStore = newStateArg;
|
// Save to WebStore first to ensure atomicity - if save fails, memory state remains unchanged
|
||||||
await this.notifyChange();
|
|
||||||
|
|
||||||
// Save state to WebStore if initialized
|
|
||||||
if (this.webStore) {
|
if (this.webStore) {
|
||||||
await this.webStore.set(String(this.name), newStateArg);
|
await this.webStore.set(String(this.name), newStateArg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update in-memory state after successful persistence
|
||||||
|
this.stateStore = newStateArg;
|
||||||
|
await this.notifyChange();
|
||||||
|
|
||||||
return this.stateStore;
|
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;
|
* creates a cumulative notification by adding a change notification at the end of the call stack;
|
||||||
*/
|
*/
|
||||||
public notifyChangeCumulative() {
|
public notifyChangeCumulative() {
|
||||||
// TODO: check viability
|
// Debounce: clear any pending notification
|
||||||
setTimeout(async () => {
|
if (this.pendingCumulativeNotification) {
|
||||||
|
clearTimeout(this.pendingCumulativeNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingCumulativeNotification = setTimeout(async () => {
|
||||||
|
this.pendingCumulativeNotification = null;
|
||||||
if (this.stateStore) {
|
if (this.stateStore) {
|
||||||
await this.notifyChange();
|
await this.notifyChange();
|
||||||
}
|
}
|
||||||
@@ -122,7 +131,8 @@ export class StatePart<TStatePartName, TStatePayload> {
|
|||||||
try {
|
try {
|
||||||
return selectorFn(stateArg);
|
return selectorFn(stateArg);
|
||||||
} catch (e) {
|
} 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
|
* waits until a certain part of the state becomes available
|
||||||
* @param selectorFn
|
* @param selectorFn
|
||||||
|
* @param timeoutMs - optional timeout in milliseconds to prevent indefinite waiting
|
||||||
*/
|
*/
|
||||||
public async waitUntilPresent<T = TStatePayload>(
|
public async waitUntilPresent<T = TStatePayload>(
|
||||||
selectorFn?: (state: TStatePayload) => T
|
selectorFn?: (state: TStatePayload) => T,
|
||||||
|
timeoutMs?: number
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const done = plugins.smartpromise.defer<T>();
|
const done = plugins.smartpromise.defer<T>();
|
||||||
const selectedObservable = this.select(selectorFn);
|
const selectedObservable = this.select(selectorFn);
|
||||||
const subscription = selectedObservable.subscribe(async (value) => {
|
let resolved = false;
|
||||||
if (value) {
|
|
||||||
|
const subscription = selectedObservable.subscribe((value) => {
|
||||||
|
if (value && !resolved) {
|
||||||
|
resolved = true;
|
||||||
done.resolve(value);
|
done.resolve(value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const result = await done.promise;
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
if (timeoutMs) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
|
done.reject(new Error(`waitUntilPresent timed out after ${timeoutMs}ms`));
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await done.promise;
|
||||||
return result;
|
return result;
|
||||||
|
} finally {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,6 +206,6 @@ export class StatePart<TStatePartName, TStatePayload> {
|
|||||||
) {
|
) {
|
||||||
const resultPromise = funcArg(this);
|
const resultPromise = funcArg(this);
|
||||||
this.cumulativeDeferred.addPromise(resultPromise);
|
this.cumulativeDeferred.addPromise(resultPromise);
|
||||||
this.setState(await resultPromise);
|
await this.setState(await resultPromise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user