From 6436370abca17a7b8d4ff964533baa781f31e57a Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 2 Feb 2026 01:05:57 +0000 Subject: [PATCH] fix(smartstate): prevent duplicate statepart creation and fix persistence/notification race conditions --- changelog.md | 12 ++++++ ts/00_commitinfo_data.ts | 2 +- ts/smartstate.classes.smartstate.ts | 39 ++++++++++++++---- ts/smartstate.classes.statepart.ts | 61 ++++++++++++++++++++++------- 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/changelog.md b/changelog.md index 7e2beac..9969a27 100644 --- a/changelog.md +++ b/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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 9ff5e76..eede3ee 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/smartstate.classes.smartstate.ts b/ts/smartstate.classes.smartstate.ts index ed913be..279f1bf 100644 --- a/ts/smartstate.classes.smartstate.ts +++ b/ts/smartstate.classes.smartstate.ts @@ -9,6 +9,8 @@ export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent'; export class Smartstate { public statePartMap: { [key in StatePartNameType]?: StatePart } = {}; + private pendingStatePartCreation: Map>> = new Map(); + constructor() {} /** @@ -26,8 +28,14 @@ export class Smartstate { initialArg?: PayloadType, initMode: TInitMode = 'soft' ): Promise> { + // Return pending creation if one exists to prevent duplicate state parts + const pending = this.pendingStatePartCreation.get(statePartNameArg); + if (pending) { + return pending as Promise>; + } + const existingStatePart = this.statePartMap[statePartNameArg]; - + if (existingStatePart) { switch (initMode) { case 'mandatory': @@ -36,7 +44,7 @@ export class Smartstate { ); case 'force': // Force mode: create new state part - return this.createStatePart(statePartNameArg, initialArg, initMode); + break; // Fall through to creation case 'soft': case 'persistent': default: @@ -50,7 +58,16 @@ export class Smartstate { `State part '${statePartNameArg}' does not exist and no initial state provided` ); } - return this.createStatePart(statePartNameArg, initialArg, initMode); + } + + const creationPromise = this.createStatePart(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 { ); 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; } diff --git a/ts/smartstate.classes.statepart.ts b/ts/smartstate.classes.statepart.ts index 4c61d80..039f242 100644 --- a/ts/smartstate.classes.statepart.ts +++ b/ts/smartstate.classes.statepart.ts @@ -7,6 +7,8 @@ export class StatePart { public stateStore: TStatePayload | undefined; private cumulativeDeferred = plugins.smartpromise.cumulativeDefer(); + private pendingCumulativeNotification: ReturnType | null = null; + private webStoreOptions: plugins.webstore.IWebStoreOptions; private webStore: plugins.webstore.WebStore | null = null; // Add WebStore instance @@ -50,14 +52,16 @@ export class StatePart { 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 { * 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 { 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 { /** * 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( - selectorFn?: (state: TStatePayload) => T + selectorFn?: (state: TStatePayload) => T, + timeoutMs?: number ): Promise { const done = plugins.smartpromise.defer(); 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 | 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 { ) { const resultPromise = funcArg(this); this.cumulativeDeferred.addPromise(resultPromise); - this.setState(await resultPromise); + await this.setState(await resultPromise); } }