Compare commits

...

4 Commits

Author SHA1 Message Date
d8decdb3e5 v2.0.30
Some checks failed
Default (tags) / security (push) Successful in 24s
Default (tags) / test (push) Failing after 39s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-02 01:07:38 +00:00
03cfee2003 fix(config): update npmextra configuration and improve README: rename package keys, add release registry config, clarify waitUntilPresent timeout and notification/persistence behavior 2026-02-02 01:07:38 +00:00
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
7 changed files with 131 additions and 35 deletions

View File

@@ -1,5 +1,28 @@
# Changelog # Changelog
## 2026-02-02 - 2.0.30 - fix(config)
update npmextra configuration and improve README: rename package keys, add release registry config, clarify waitUntilPresent timeout and notification/persistence behavior
- Renamed npmextra keys: 'gitzone' → '@git.zone/cli' and 'tsdoc' → '@git.zone/tsdoc'
- Added release configuration for @git.zone/cli including registries (verdaccio and npm) and accessLevel
- Removed top-level 'npmci' section
- Added new '@ship.zone/szci' entry with npmGlobalTools
- README: added waitUntilPresent timeout example with error handling
- README: clarified notifyChangeCumulative is debounced and documented persistence behavior (merge with defaults, atomic writes)
- README: documented concurrency/race-condition safety and timeout support for waitUntilPresent
## 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,8 +1,4 @@
{ {
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public"
},
"@git.zone/tsbundle": { "@git.zone/tsbundle": {
"bundles": [ "bundles": [
{ {
@@ -14,7 +10,7 @@
} }
] ]
}, },
"gitzone": { "@git.zone/cli": {
"projectType": "npm", "projectType": "npm",
"module": { "module": {
"githost": "code.foss.global", "githost": "code.foss.global",
@@ -35,9 +31,19 @@
"asynchronous state", "asynchronous state",
"cumulative notification" "cumulative notification"
] ]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
} }
}, },
"tsdoc": { "@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n" "legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
},
"@ship.zone/szci": {
"npmGlobalTools": []
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartstate", "name": "@push.rocks/smartstate",
"version": "2.0.28", "version": "2.0.30",
"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

@@ -145,19 +145,26 @@ if (currentState) {
console.log('Current user:', currentState.username); console.log('Current user:', currentState.username);
} }
// Wait for a specific state condition // Wait for state to be present
await userStatePart.waitUntilPresent(); await userStatePart.waitUntilPresent();
// Wait for a specific property to be present // Wait for a specific property to be present
await userStatePart.waitUntilPresent(state => state.username); await userStatePart.waitUntilPresent(state => state.username);
// Wait with a timeout (throws error if condition not met within timeout)
try {
await userStatePart.waitUntilPresent(state => state.username, 5000); // 5 second timeout
} catch (error) {
console.error('Timed out waiting for username');
}
// Setup initial state with async operations // Setup initial state with async operations
await userStatePart.stateSetup(async (statePart) => { await userStatePart.stateSetup(async (statePart) => {
const userData = await fetchUserData(); const userData = await fetchUserData();
return { ...statePart.getState(), ...userData }; return { ...statePart.getState(), ...userData };
}); });
// Defer notification to end of call stack // Defer notification to end of call stack (debounced)
userStatePart.notifyChangeCumulative(); userStatePart.notifyChangeCumulative();
``` ```
@@ -168,7 +175,7 @@ userStatePart.notifyChangeCumulative();
```typescript ```typescript
const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>( const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>(
AppStateParts.SettingsState, AppStateParts.SettingsState,
{ theme: 'light' }, // Initial state { theme: 'light' }, // Initial/default state
'persistent' // Mode 'persistent' // Mode
); );
``` ```
@@ -176,7 +183,8 @@ const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>(
Persistent state automatically: Persistent state automatically:
- Saves state changes to IndexedDB - Saves state changes to IndexedDB
- Restores state on application restart - Restores state on application restart
- Manages storage with configurable database and store names - Merges persisted values with defaults (persisted values take precedence)
- Ensures atomic writes (persistence happens before memory update)
### State Validation ### State Validation
@@ -200,9 +208,10 @@ class ValidatedStatePart<T> extends StatePart<string, T> {
- **🔒 Async State Hash Detection**: Uses SHA256 hashing to detect actual state changes, preventing unnecessary notifications when state values haven't truly changed - **🔒 Async State Hash Detection**: Uses SHA256 hashing to detect actual state changes, preventing unnecessary notifications when state values haven't truly changed
- **🚫 Duplicate Prevention**: Identical state updates are automatically filtered out - **🚫 Duplicate Prevention**: Identical state updates are automatically filtered out
- **📦 Cumulative Notifications**: Batch multiple state changes into a single notification using `notifyChangeCumulative()` - **📦 Cumulative Notifications**: Batch multiple state changes into a single notification using `notifyChangeCumulative()` with automatic debouncing
- **🎯 Selective Subscriptions**: Use selectors to subscribe only to specific state properties - **🎯 Selective Subscriptions**: Use selectors to subscribe only to specific state properties
- **✨ Undefined State Filtering**: The `select()` method automatically filters out undefined states - **✨ Undefined State Filtering**: The `select()` method automatically filters out undefined states
- **⚡ Concurrent Access Safety**: Prevents race conditions when multiple calls request the same state part simultaneously
### RxJS Integration ### RxJS Integration
@@ -297,6 +306,8 @@ await loginAction.trigger({ username: 'john', email: 'john@example.com' });
| ✅ **Validated** | Built-in state validation with extensible validation logic | | ✅ **Validated** | Built-in state validation with extensible validation logic |
| 🎭 **Flexible init modes** | Choose how state parts are initialized | | 🎭 **Flexible init modes** | Choose how state parts are initialized |
| 📦 **Zero config** | Works out of the box with sensible defaults | | 📦 **Zero config** | Works out of the box with sensible defaults |
| 🛡️ **Race condition safe** | Concurrent state part creation is handled safely |
| ⏱️ **Timeout support** | `waitUntilPresent` supports optional timeouts |
## License and Legal Information ## License and Legal Information

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.30',
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();
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;
} }

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;
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);
} }
} }