diff --git a/changelog.md b/changelog.md index 9d0aa56..452c772 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 2025-07-29 - 2.0.25 - fix(core) +Major state initialization and validation improvements + +- Fixed state hash bug: Now properly compares hash values instead of storing state objects +- Fixed state initialization merge order: Initial state now correctly takes precedence over stored state +- Improved type safety: stateStore properly typed as potentially undefined +- Simplified init mode logic with clear behavior for 'soft', 'mandatory', 'force', and 'persistent' +- Added state validation with extensible validateState() method +- Made notifyChange() async to support proper hash comparison +- Enhanced select() to filter undefined states automatically +- Added comprehensive test suite for state initialization scenarios +- Updated documentation with clearer examples and improved readme + ## 2025-07-19 - 2.0.24 - fix(core) Multiple fixes and improvements diff --git a/package.json b/package.json index f25eb72..706ec9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@push.rocks/smartstate", - "version": "2.0.24", + "version": "2.0.25", "private": false, "description": "A package for handling and managing state in applications.", "main": "dist_ts/index.js", diff --git a/readme.hints.md b/readme.hints.md index 103dda3..f6ac891 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,39 +1,52 @@ # Smartstate Implementation Notes -## Current API (as of analysis) +## Current API (as of v2.0.24+) ### State Part Initialization -- State parts can be created with different init modes: 'soft', 'mandatory', 'force', 'persistent' +- State parts can be created with different init modes: 'soft' (default), 'mandatory', 'force', 'persistent' + - 'soft' - returns existing state part if exists, creates new if not + - 'mandatory' - requires state part to not exist, fails if it does + - 'force' - always creates new state part, overwriting any existing + - 'persistent' - like 'soft' but with WebStore persistence (IndexedDB) - Persistent mode automatically calls init() internally - no need to call it manually -- WebStore integration for persistent state uses IndexedDB +- State merge order fixed: initial state takes precedence over stored state ### Actions - Actions are created with `createAction()` method - Two ways to dispatch actions: 1. `stateAction.trigger(payload)` - returns Promise 2. `await statePart.dispatchAction(stateAction, payload)` - returns Promise -- Both methods now return the same Promise, providing flexibility in usage +- Both methods return the same Promise, providing flexibility in usage ### State Management Methods -- `select()` - returns Observable with startWith current state +- `select()` - returns Observable with startWith current state, filters undefined states - `waitUntilPresent()` - waits for specific state condition - `stateSetup()` - async state initialization with cumulative defer -- `notifyChangeCumulative()` - defers notification to end of call stack (no callback parameter) +- `notifyChangeCumulative()` - defers notification to end of call stack +- `getState()` - returns current state or undefined +- `setState()` - validates state before setting, notifies only on actual changes ### State Hash Detection - Uses SHA256 hash to detect actual state changes -- Bug: Currently stores the state object itself as hash instead of the actual hash -- This prevents proper duplicate notification prevention +- Fixed: Hash comparison now properly awaits async hash calculation +- Prevents duplicate notifications for identical state values +- `notifyChange()` is now async to support proper hash comparison + +### State Validation +- Basic validation ensures state is not null/undefined +- `validateState()` method can be overridden in subclasses for custom validation +- Validation runs on both setState() and when loading from persistent storage ### Type System - Can use either enums or string literal types for state part names - Test uses simple string types: `type TMyStateParts = 'testStatePart'` +- State can be undefined initially, handled properly in select() and other methods -## Fixed Issues in Documentation -1. Updated trigger() to return Promise (API enhancement) -2. Added dispatchAction as alternative method -3. Corrected notifyChangeCumulative usage -4. Clarified persistent mode auto-init -5. Added stateSetup documentation -6. Fixed state hash detection description -7. Both trigger() and dispatchAction() now return Promise for consistency \ No newline at end of file +## Recent Fixes (v2.0.24+) +1. Fixed state hash bug - now properly compares hash values instead of promises +2. Fixed state initialization merge order - initial state now takes precedence +3. Ensured stateStore is properly typed as potentially undefined +4. Simplified init mode logic with clear behavior for each mode +5. Added state validation with extensible validateState() method +6. Made notifyChange() async to support proper hash comparison +7. Updated select() to filter undefined states \ No newline at end of file diff --git a/readme.md b/readme.md index 18927ec..df2b607 100644 --- a/readme.md +++ b/readme.md @@ -1,12 +1,19 @@ # @push.rocks/smartstate -A package for handling and managing state in applications +A powerful TypeScript library for elegant state management using RxJS and reactive programming patterns ## Install -To install `@push.rocks/smartstate`, you can use pnpm (Performant Node Package Manager). Run the following command in your terminal: +To install `@push.rocks/smartstate`, you can use pnpm, npm, or yarn: ```bash +# Using pnpm (recommended) pnpm install @push.rocks/smartstate --save + +# Using npm +npm install @push.rocks/smartstate --save + +# Using yarn +yarn add @push.rocks/smartstate ``` This will add `@push.rocks/smartstate` to your project's dependencies. @@ -35,10 +42,10 @@ const myAppSmartState = new Smartstate(); When creating state parts, you can specify different initialization modes: -- **`'soft'`** - Allows existing state parts to remain (default behavior) -- **`'mandatory'`** - Fails if there's an existing state part with the same name -- **`'force'`** - Overwrites any existing state part -- **`'persistent'`** - Enables WebStore persistence using IndexedDB +- **`'soft'`** (default) - Returns existing state part if it exists, creates new if not +- **`'mandatory'`** - Requires state part to not exist, fails if it does +- **`'force'`** - Always creates new state part, overwriting any existing one +- **`'persistent'`** - Like 'soft' but with WebStore persistence using IndexedDB ### Defining State Parts @@ -79,6 +86,7 @@ const userStatePart = await myAppSmartState.getStatePart( You can subscribe to changes in a state part to perform actions accordingly: ```typescript +// The select() method automatically filters out undefined states userStatePart.select().subscribe((currentState) => { console.log(`User Logged In: ${currentState.isLoggedIn}`); }); @@ -134,6 +142,12 @@ Both methods return a Promise with the new state, giving you flexibility in how `StatePart` provides several useful methods for state management: ```typescript +// Get current state (may be undefined initially) +const currentState = userStatePart.getState(); +if (currentState) { + console.log('Current user:', currentState.username); +} + // Wait for a specific state condition await userStatePart.waitUntilPresent(); @@ -170,13 +184,33 @@ Persistent state automatically: - Restores state on application restart - Manages storage with configurable database and store names +### State Validation + +`Smartstate` includes built-in state validation to ensure data integrity: + +```typescript +// Basic validation (built-in) +// Ensures state is not null or undefined +await userStatePart.setState(null); // Throws error: Invalid state structure + +// Custom validation by extending StatePart +class ValidatedStatePart extends StatePart { + protected validateState(stateArg: any): stateArg is T { + // Add your custom validation logic + return super.validateState(stateArg) && /* your validation */; + } +} +``` + ### Performance Optimization -`Smartstate` includes built-in performance optimizations: +`Smartstate` includes advanced performance optimizations: -- **State Change Detection**: Detects actual state changes to prevent 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 - **Cumulative Notifications**: Batch multiple state changes into a single notification using `notifyChangeCumulative()` - **Selective Subscriptions**: Use selectors to subscribe only to specific state properties +- **Undefined State Filtering**: The `select()` method automatically filters out undefined states ### RxJS Integration @@ -202,19 +236,75 @@ userStatePart.select(state => state.username) }); ``` -### Comprehensive Usage +### Complete Example -Putting it all together, `@push.rocks/smartstate` offers a flexible and powerful pattern for managing application state. By modularizing state parts, subscribing to state changes, and controlling state modifications through actions, developers can maintain a clean and scalable architecture. Combining these strategies with persistent states unlocks the full potential for creating dynamic and user-friendly applications. +Here's a comprehensive example showcasing the power of `@push.rocks/smartstate`: -Key features: -- **Type-safe state management** with full TypeScript support -- **Reactive state updates** using RxJS observables -- **Persistent state** with IndexedDB storage -- **Performance optimized** with state hash detection -- **Modular architecture** with separate state parts -- **Action-based updates** for predictable state modifications +```typescript +import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate'; -For more complex scenarios, consider combining multiple state parts, creating hierarchical state structures, and integrating with other state management solutions as needed. With `@push.rocks/smartstate`, the possibilities are vast, empowering you to tailor the state management approach to fit the unique requirements of your project. +// Define your state structure +type AppStateParts = 'user' | 'settings' | 'cart'; + +interface IUserState { + isLoggedIn: boolean; + username?: string; + email?: string; +} + +interface ICartState { + items: Array<{ id: string; quantity: number }>; + total: number; +} + +// Create the smartstate instance +const appState = new Smartstate(); + +// Initialize state parts +const userState = await appState.getStatePart('user', { + isLoggedIn: false +}); + +const cartState = await appState.getStatePart('cart', { + items: [], + total: 0 +}, 'persistent'); // Persists across sessions + +// Create actions +const loginAction = userState.createAction<{ username: string; email: string }>( + async (statePart, payload) => { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + return { + isLoggedIn: true, + username: payload.username, + email: payload.email + }; + } +); + +// Subscribe to changes +userState.select(state => state.isLoggedIn).subscribe(isLoggedIn => { + console.log('Login status changed:', isLoggedIn); +}); + +// Dispatch actions +await loginAction.trigger({ username: 'john', email: 'john@example.com' }); +``` + +### Key Features + +`@push.rocks/smartstate` provides a robust foundation for state management: + +- **🎯 Type-safe** - Full TypeScript support with intelligent type inference +- **⚡ Performance optimized** - Async state hash detection prevents unnecessary re-renders +- **💾 Persistent state** - Built-in IndexedDB support for state persistence +- **🔄 Reactive** - Powered by RxJS for elegant async handling +- **🧩 Modular** - Organize state into logical, reusable parts +- **✅ Validated** - Built-in state validation with extensible validation logic +- **🎭 Flexible init modes** - Choose how state parts are initialized +- **📦 Zero config** - Works out of the box with sensible defaults ## License and Legal Information diff --git a/test/test.both.ts b/test/test.both.ts index 21b6279..9729c76 100644 --- a/test/test.both.ts +++ b/test/test.both.ts @@ -55,4 +55,4 @@ tap.test('should dispatch a state action', async (tools) => { await done.promise; }); -tap.start(); +export default tap.start(); diff --git a/test/test.initialization.both.ts b/test/test.initialization.both.ts new file mode 100644 index 0000000..00d9ed4 --- /dev/null +++ b/test/test.initialization.both.ts @@ -0,0 +1,157 @@ +import { expect, tap } from '@push.rocks/tapbundle'; +import * as smartstate from '../ts/index.js'; + +type TTestStateParts = 'initTest' | 'persistTest' | 'forceTest'; + +interface ITestState { + value: number; + nested: { + data: string; + }; +} + +tap.test('should handle soft init mode (default)', async () => { + const state = new smartstate.Smartstate(); + + // First creation + const statePart1 = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }); + expect(statePart1.getState()).toEqual({ + value: 1, + nested: { data: 'initial' } + }); + + // Second call should return existing + const statePart2 = await state.getStatePart('initTest'); + expect(statePart1).toEqual(statePart2); +}); + +tap.test('should handle mandatory init mode', async () => { + const state = new smartstate.Smartstate(); + + // First creation should succeed + const statePart1 = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }, 'mandatory'); + expect(statePart1).toBeInstanceOf(smartstate.StatePart); + + // Second call with mandatory should fail + let error: Error | null = null; + try { + await state.getStatePart('initTest', { + value: 2, + nested: { data: 'second' } + }, 'mandatory'); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error?.message).toMatch(/already exists.*mandatory/); +}); + +tap.test('should handle force init mode', async () => { + const state = new smartstate.Smartstate(); + + // First creation + const statePart1 = await state.getStatePart('forceTest', { + value: 1, + nested: { data: 'initial' } + }); + expect(statePart1.getState()?.value).toEqual(1); + + // Force should create new state part + const statePart2 = await state.getStatePart('forceTest', { + value: 2, + nested: { data: 'forced' } + }, 'force'); + expect(statePart2.getState()?.value).toEqual(2); + expect(statePart1).not.toEqual(statePart2); +}); + +tap.test('should handle missing initial state error', async () => { + const state = new smartstate.Smartstate(); + + let error: Error | null = null; + try { + await state.getStatePart('initTest'); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error?.message).toMatch(/does not exist.*no initial state/); +}); + +tap.test('should handle state validation', async () => { + const state = new smartstate.Smartstate(); + + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }); + + // Setting null should fail validation + let error: Error | null = null; + try { + await statePart.setState(null as any); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error?.message).toMatch(/Invalid state structure/); +}); + +tap.test('should handle undefined state in select', async () => { + const state = new smartstate.Smartstate(); + const statePart = new smartstate.StatePart('initTest'); + + // Select should filter out undefined states + const values: (ITestState | undefined)[] = []; + statePart.select().subscribe(val => values.push(val)); + + // Initially undefined, should not emit + expect(values).toHaveLength(0); + + // After setting state, should emit + await statePart.setState({ + value: 1, + nested: { data: 'test' } + }); + + expect(values).toHaveLength(1); + expect(values[0]).toEqual({ + value: 1, + nested: { data: 'test' } + }); +}); + +tap.test('should not notify on duplicate state', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }); + + let notificationCount = 0; + // Use select() to get initial value + changes + statePart.select().subscribe(() => notificationCount++); + + // Should have received initial state + expect(notificationCount).toEqual(1); + + // Set same state multiple times + await statePart.setState({ value: 1, nested: { data: 'initial' } }); + await statePart.setState({ value: 1, nested: { data: 'initial' } }); + await statePart.setState({ value: 1, nested: { data: 'initial' } }); + + // Should still be 1 (no new notifications for duplicate state) + expect(notificationCount).toEqual(1); + + // Change state should notify + await statePart.setState({ value: 2, nested: { data: 'changed' } }); + expect(notificationCount).toEqual(2); +}); + +export default tap.start(); \ No newline at end of file diff --git a/ts/smartstate.classes.smartstate.ts b/ts/smartstate.classes.smartstate.ts index 6802735..ed913be 100644 --- a/ts/smartstate.classes.smartstate.ts +++ b/ts/smartstate.classes.smartstate.ts @@ -13,9 +13,10 @@ export class Smartstate { /** * Allows getting and initializing a new statepart - * initMode === 'soft' it will allow existing stateparts - * initMode === 'mandatory' will fail if there is an existing statepart - * initMode === 'force' will overwrite any existing statepart + * initMode === 'soft' (default) - returns existing statepart if exists, creates new if not + * initMode === 'mandatory' - requires statepart to not exist, fails if it does + * initMode === 'force' - always creates new statepart, overwriting any existing + * initMode === 'persistent' - like 'soft' but with webstore persistence * @param statePartNameArg * @param initialArg * @param initMode @@ -23,19 +24,30 @@ export class Smartstate { public async getStatePart( statePartNameArg: StatePartNameType, initialArg?: PayloadType, - initMode?: TInitMode + initMode: TInitMode = 'soft' ): Promise> { - if (this.statePartMap[statePartNameArg]) { - if (initialArg && (!initMode || initMode !== 'soft')) { - throw new Error( - `${statePartNameArg} already exists, yet you try to set an initial state again` - ); + const existingStatePart = this.statePartMap[statePartNameArg]; + + if (existingStatePart) { + switch (initMode) { + case 'mandatory': + throw new Error( + `State part '${statePartNameArg}' already exists, but initMode is 'mandatory'` + ); + case 'force': + // Force mode: create new state part + return this.createStatePart(statePartNameArg, initialArg, initMode); + case 'soft': + case 'persistent': + default: + // Return existing state part + return existingStatePart as StatePart; } - return this.statePartMap[statePartNameArg] as StatePart; } else { + // State part doesn't exist if (!initialArg) { throw new Error( - `${statePartNameArg} does not yet exist, yet you don't provide an initial state` + `State part '${statePartNameArg}' does not exist and no initial state provided` ); } return this.createStatePart(statePartNameArg, initialArg, initMode); @@ -46,11 +58,12 @@ export class Smartstate { * Creates a statepart * @param statePartName * @param initialPayloadArg + * @param initMode */ private async createStatePart( statePartName: StatePartNameType, initialPayloadArg: PayloadType, - initMode?: TInitMode + initMode: TInitMode = 'soft' ): Promise> { const newState = new StatePart( statePartName, @@ -64,8 +77,8 @@ export class Smartstate { await newState.init(); const currentState = newState.getState(); await newState.setState({ - ...initialPayloadArg, ...currentState, + ...initialPayloadArg, }); this.statePartMap[statePartName] = newState; return newState; diff --git a/ts/smartstate.classes.statepart.ts b/ts/smartstate.classes.statepart.ts index c3669fd..ba52b31 100644 --- a/ts/smartstate.classes.statepart.ts +++ b/ts/smartstate.classes.statepart.ts @@ -4,7 +4,7 @@ import { StateAction, type IActionDef } from './smartstate.classes.stateaction.j export class StatePart { public name: TStatePartName; public state = new plugins.smartrx.rxjs.Subject(); - public stateStore: TStatePayload; + public stateStore: TStatePayload | undefined; private cumulativeDeferred = plugins.smartpromise.cumulativeDefer(); private webStoreOptions: plugins.webstore.IWebStoreOptions; @@ -27,9 +27,9 @@ export class StatePart { this.webStore = new plugins.webstore.WebStore(this.webStoreOptions); await this.webStore.init(); const storedState = await this.webStore.get(String(this.name)); - if (storedState) { + if (storedState && this.validateState(storedState)) { this.stateStore = storedState; - this.notifyChange(); + await this.notifyChange(); } } } @@ -37,7 +37,7 @@ export class StatePart { /** * gets the state from the state store */ - public getState(): TStatePayload { + public getState(): TStatePayload | undefined { return this.stateStore; } @@ -46,8 +46,13 @@ export class StatePart { * @param newStateArg */ public async setState(newStateArg: TStatePayload) { + // Validate state structure + if (!this.validateState(newStateArg)) { + throw new Error(`Invalid state structure for state part '${this.name}'`); + } + this.stateStore = newStateArg; - this.notifyChange(); + await this.notifyChange(); // Save state to WebStore if initialized if (this.webStore) { @@ -56,21 +61,34 @@ export class StatePart { return this.stateStore; } + /** + * Validates state structure - can be overridden for custom validation + * @param stateArg + */ + protected validateState(stateArg: any): stateArg is TStatePayload { + // Basic validation - ensure state is not null/undefined + // Subclasses can override for more specific validation + return stateArg !== null && stateArg !== undefined; + } + /** * notifies of a change on the state */ - public notifyChange() { - const createStateHash = (stateArg: any) => { - return plugins.smarthashWeb.sha256FromString(plugins.smartjson.stringify(stateArg)); + public async notifyChange() { + if (!this.stateStore) { + return; + } + const createStateHash = async (stateArg: any) => { + return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stringify(stateArg)); }; + const currentHash = await createStateHash(this.stateStore); if ( - this.stateStore && this.lastStateNotificationPayloadHash && - createStateHash(this.stateStore) === this.lastStateNotificationPayloadHash + currentHash === this.lastStateNotificationPayloadHash ) { return; } else { - this.lastStateNotificationPayloadHash = this.stateStore; + this.lastStateNotificationPayloadHash = currentHash; } this.state.next(this.stateStore); } @@ -81,7 +99,11 @@ export class StatePart { */ public notifyChangeCumulative() { // TODO: check viability - setTimeout(() => this.state.next(this.stateStore), 0); + setTimeout(async () => { + if (this.stateStore) { + await this.notifyChange(); + } + }, 0); } /** @@ -95,6 +117,7 @@ export class StatePart { } const mapped = this.state.pipe( plugins.smartrx.rxjs.ops.startWith(this.getState()), + plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined), plugins.smartrx.rxjs.ops.map((stateArg) => { try { return selectorFn(stateArg);