Compare commits

..

2 Commits

Author SHA1 Message Date
f6a3e71f0a v2.0.29
Some checks failed
Default (tags) / security (push) Successful in 29s
Default (tags) / test (push) Failing after 41s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-02 01:05:57 +00:00
6436370abc fix(smartstate): prevent duplicate statepart creation and fix persistence/notification race conditions 2026-02-02 01:05:57 +00:00
5 changed files with 92 additions and 24 deletions

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartstate",
"version": "2.0.28",
"version": "2.0.29",
"private": false,
"description": "A package for handling and managing state in applications.",
"main": "dist_ts/index.js",

View File

@@ -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.'
}

View File

@@ -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,8 +28,14 @@ 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) {
switch (initMode) {
case 'mandatory':
@@ -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;
}

View File

@@ -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
@@ -50,14 +52,16 @@ export class StatePart<TStatePartName, TStatePayload> {
if (!this.validateState(newStateArg)) {
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);
}
}