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

View File

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

View File

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

View File

@@ -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();
await newState.setState({
...currentState, if (initMode === 'persistent' && currentState !== undefined) {
...initialPayloadArg, // 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; this.statePartMap[statePartName] = newState;
return newState; return newState;
} }

View File

@@ -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;
subscription.unsubscribe(); let timeoutId: ReturnType<typeof setTimeout> | undefined;
return result; 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); const resultPromise = funcArg(this);
this.cumulativeDeferred.addPromise(resultPromise); this.cumulativeDeferred.addPromise(resultPromise);
this.setState(await resultPromise); await this.setState(await resultPromise);
} }
} }