From 575477df096902ab307e9f4420fd2544e1390621 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 27 Feb 2026 11:40:07 +0000 Subject: [PATCH] feat(smartstate): Add middleware, computed, batching, selector memoization, AbortSignal support, and Web Component Context Protocol provider --- changelog.md | 12 + npmextra.json | 10 +- package.json | 11 +- pnpm-lock.yaml | 3 - readme.hints.md | 73 ++-- readme.md | 465 +++++++++++------------ test/test.initialization.ts | 547 ++++++++++++++++++++++++++-- ts/00_commitinfo_data.ts | 4 +- ts/index.ts | 2 + ts/smartstate.classes.computed.ts | 16 + ts/smartstate.classes.smartstate.ts | 68 +++- ts/smartstate.classes.statepart.ts | 163 +++++++-- ts/smartstate.contextprovider.ts | 61 ++++ 13 files changed, 1091 insertions(+), 344 deletions(-) create mode 100644 ts/smartstate.classes.computed.ts create mode 100644 ts/smartstate.contextprovider.ts diff --git a/changelog.md b/changelog.md index e9bbcc7..0e0bc5d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2026-02-27 - 2.1.0 - feat(smartstate) +Add middleware, computed, batching, selector memoization, AbortSignal support, and Web Component Context Protocol provider + +- Introduce StatePart middleware API (addMiddleware) — middleware runs sequentially before validation/persistence and can transform or reject a state change. +- Add computed derived observables: standalone computed(sources, fn) and Smartstate.computed to derive values from multiple state parts (lazy subscription). +- Add batching support via Smartstate.batch(fn), isBatching flag, and deferred notifications to batch multiple updates and flush only at the outermost level. +- Enhance select() with selector memoization (WeakMap cache and shareReplay) and optional AbortSignal support (auto-unsubscribe). +- Extend waitUntilPresent() to accept timeout and AbortSignal options and maintain backward-compatible numeric timeout argument. +- Add attachContextProvider(element, options) to bridge state parts to Web Component Context Protocol (context-request events) with subscribe/unsubscribe handling. +- Update StatePart.setState to run middleware, persist processed state atomically, and defer notifications to batching when applicable. +- Tests and README updated to document new features, behaviors, and examples. + ## 2026-02-27 - 2.0.31 - fix(deps) bump devDependencies and fix README license path diff --git a/npmextra.json b/npmextra.json index 42dae64..c99c467 100644 --- a/npmextra.json +++ b/npmextra.json @@ -16,7 +16,7 @@ "githost": "code.foss.global", "gitscope": "push.rocks", "gitrepo": "smartstate", - "description": "A package for handling and managing state in applications.", + "description": "A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.", "npmPackagename": "@push.rocks/smartstate", "license": "MIT", "keywords": [ @@ -29,7 +29,13 @@ "state selection", "state notification", "asynchronous state", - "cumulative notification" + "cumulative notification", + "middleware", + "computed state", + "batch updates", + "context protocol", + "web components", + "AbortSignal" ] }, "release": { diff --git a/package.json b/package.json index d6fe429..ed0e5f4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@push.rocks/smartstate", "version": "2.0.31", "private": false, - "description": "A package for handling and managing state in applications.", + "description": "A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.", "main": "dist_ts/index.js", "typings": "dist_ts/index.d.ts", "type": "module", @@ -22,7 +22,6 @@ "@types/node": "^25.3.2" }, "dependencies": { - "@push.rocks/lik": "^6.2.2", "@push.rocks/smarthash": "^3.2.6", "@push.rocks/smartjson": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", @@ -54,7 +53,13 @@ "state selection", "state notification", "asynchronous state", - "cumulative notification" + "cumulative notification", + "middleware", + "computed state", + "batch updates", + "context protocol", + "web components", + "AbortSignal" ], "homepage": "https://code.foss.global/push.rocks/smartstate", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74de100..44f9158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@push.rocks/lik': - specifier: ^6.2.2 - version: 6.2.2 '@push.rocks/smarthash': specifier: ^3.2.6 version: 3.2.6 diff --git a/readme.hints.md b/readme.hints.md index 8386c2c..7ebda8e 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,6 +1,6 @@ # Smartstate Implementation Notes -## Current API (as of v2.0.28+) +## Current API (as of v2.0.31) ### State Part Initialization - State parts can be created with different init modes: 'soft' (default), 'mandatory', 'force', 'persistent' @@ -8,53 +8,70 @@ - '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 +- Persistent mode automatically calls init() internally - 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 return the same Promise, providing flexibility in usage +- Two ways to dispatch: `stateAction.trigger(payload)` or `statePart.dispatchAction(stateAction, payload)` +- Both return Promise ### State Management Methods -- `select()` - returns Observable with startWith current state, filters undefined states -- `waitUntilPresent()` - waits for specific state condition +- `select(fn?, { signal? })` - returns Observable, memoized by selector fn ref, supports AbortSignal +- `waitUntilPresent(fn?, number | { timeoutMs?, signal? })` - waits for state condition, backward compat with number arg - `stateSetup()` - async state initialization with cumulative defer - `notifyChangeCumulative()` - defers notification to end of call stack - `getState()` - returns current state or undefined -- `setState()` - validates state before setting, notifies only on actual changes +- `setState()` - runs middleware, validates, persists, notifies +- `addMiddleware(fn)` - intercepts setState, returns removal function + +### Middleware +- Type: `(newState, oldState) => newState | Promise` +- Runs sequentially in insertion order before validation/persistence +- Throw to reject state changes (atomic — state unchanged on error) +- Does NOT run during initial createStatePart() hydration + +### Selector Memoization +- Uses WeakMap for fn-keyed cache +- `defaultSelectObservable` for no-arg select() +- Wrapped in `shareReplay({ bufferSize: 1, refCount: true })` +- NOT cached when AbortSignal is provided + +### Batch Updates +- `smartstate.batch(async () => {...})` — defers notifications until batch completes +- Supports nesting — only flushes at outermost level +- StatePart has `smartstateRef` set by `createStatePart()` for batch awareness +- State parts created via `new StatePart()` directly work without batching + +### Computed State +- `computed(sources, fn)` — standalone function using `combineLatest` + `map` +- Also available as `smartstate.computed(sources, fn)` +- Lazy — only subscribes when subscribed to + +### Context Protocol Bridge +- `attachContextProvider(element, { context, statePart, selectorFn? })` — returns cleanup fn +- Listens for `context-request` CustomEvent on element +- Supports one-shot and subscription modes +- Works with Lit @consume(), FAST, or any Context Protocol consumer ### State Hash Detection - Uses SHA256 hash to detect actual state changes -- Fixed: Hash comparison now properly awaits async hash calculation +- Hash comparison 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 +- `validateState()` can be overridden in subclasses -### 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 +### Key Notes +- `smartstateRef` creates circular ref between StatePart and Smartstate +- Use `===` not deep equality for StatePart comparison in tests +- Direct rxjs imports used for: Observable, shareReplay, takeUntil, combineLatest, map -## 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 - -## Dependency Versions (v2.0.30) +## Dependency Versions (v2.0.31) - @git.zone/tsbuild: ^4.1.2 - @git.zone/tsbundle: ^2.9.0 - @git.zone/tsrun: ^2.0.1 - @git.zone/tstest: ^3.1.8 - @push.rocks/smartjson: ^6.0.0 -- @types/node: ^25.3.2 \ No newline at end of file +- @types/node: ^25.3.2 diff --git a/readme.md b/readme.md index d200276..704c1d4 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # @push.rocks/smartstate -A powerful TypeScript library for elegant state management using RxJS and reactive programming patterns 🚀 +A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀 ## Issue Reporting and Security @@ -8,306 +8,311 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community ## Install -To install `@push.rocks/smartstate`, you can use pnpm, npm, or yarn: +```bash +pnpm install @push.rocks/smartstate --save +``` + +Or with npm: ```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 ``` ## Usage -The `@push.rocks/smartstate` library provides an elegant way to handle state within your JavaScript or TypeScript projects, leveraging the power of Reactive Extensions (RxJS) and a structured state management strategy. - -### Getting Started - -Import the necessary components from the library: +### Quick Start ```typescript -import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate'; +import { Smartstate } from '@push.rocks/smartstate'; + +// 1. Define your state part names +type AppParts = 'user' | 'settings'; + +// 2. Create the root instance +const state = new Smartstate(); + +// 3. Create state parts with initial values +const userState = await state.getStatePart<{ name: string; loggedIn: boolean }>('user', { + name: '', + loggedIn: false, +}); + +// 4. Subscribe to changes +userState.select((s) => s.name).subscribe((name) => { + console.log('Name changed:', name); +}); + +// 5. Update state +await userState.setState({ name: 'Alice', loggedIn: true }); ``` -### Creating a SmartState Instance +### State Parts & Init Modes -`Smartstate` acts as the container for your state parts. Think of it as the root of your state management structure: +State parts are isolated, typed units of state. Create them with `getStatePart()`: ```typescript -const myAppSmartState = new Smartstate(); +const part = await state.getStatePart(name, initialState, initMode); ``` -### Understanding Init Modes +| Init Mode | Behavior | +|-----------|----------| +| `'soft'` (default) | Returns existing if found, creates new otherwise | +| `'mandatory'` | Throws if state part already exists | +| `'force'` | Always creates new, overwrites existing | +| `'persistent'` | Like `'soft'` but persists to IndexedDB via WebStore | -When creating state parts, you can specify different initialization modes: - -| Mode | Description | -|------|-------------| -| `'soft'` | Default. Returns existing state part if it exists, creates new if not | -| `'mandatory'` | Requires state part to not exist, throws error 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 - -State parts represent separable sections of your state, making it easier to manage and modularize. Define state part names using either enums or string literal types: +#### Persistent State ```typescript -// Option 1: Using enums -enum AppStateParts { - UserState = 'UserState', - SettingsState = 'SettingsState' -} - -// Option 2: Using string literal types (simpler approach) -type AppStateParts = 'UserState' | 'SettingsState'; +const settings = await state.getStatePart('settings', { theme: 'dark' }, 'persistent'); +// Automatically saved to IndexedDB. On next app load, persisted values override defaults. ``` -Create a state part within your `Smartstate` instance: +### Selecting State + +`select()` returns an RxJS Observable that emits the current value immediately and on every change: ```typescript -interface IUserState { - isLoggedIn: boolean; - username?: string; -} +// Full state +userState.select().subscribe((state) => console.log(state)); -const userStatePart = await myAppSmartState.getStatePart( - AppStateParts.UserState, - { isLoggedIn: false }, // Initial state - 'soft' // Init mode (optional, defaults to 'soft') +// Derived value via selector +userState.select((s) => s.name).subscribe((name) => console.log(name)); +``` + +Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers. + +#### AbortSignal Support + +Clean up subscriptions without manual `unsubscribe()`: + +```typescript +const controller = new AbortController(); + +userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => { + console.log(name); // stops receiving when aborted +}); + +// Later: clean up +controller.abort(); +``` + +### Actions + +Actions provide controlled, named state mutations: + +```typescript +const login = userState.createAction<{ name: string }>(async (statePart, payload) => { + return { ...statePart.getState(), name: payload.name, loggedIn: true }; +}); + +// Two equivalent ways to dispatch: +await login.trigger({ name: 'Alice' }); +await userState.dispatchAction(login, { name: 'Alice' }); +``` + +### Middleware + +Intercept every `setState()` call to transform, validate, or reject state changes: + +```typescript +// Logging middleware +userState.addMiddleware((newState, oldState) => { + console.log('State changing from', oldState, 'to', newState); + return newState; +}); + +// Validation middleware — throw to reject the change +userState.addMiddleware((newState) => { + if (!newState.name) throw new Error('Name is required'); + return newState; +}); + +// Transform middleware +userState.addMiddleware((newState) => { + return { ...newState, name: newState.name.trim() }; +}); + +// Removal — addMiddleware returns a dispose function +const remove = userState.addMiddleware(myMiddleware); +remove(); // middleware no longer runs +``` + +Middleware runs sequentially in insertion order. If any middleware throws, the state is unchanged (atomic). + +### Computed / Derived State + +Derive reactive values from one or more state parts: + +```typescript +import { computed } from '@push.rocks/smartstate'; + +const userState = await state.getStatePart('user', { firstName: 'Jane', lastName: 'Doe' }); +const settingsState = await state.getStatePart('settings', { locale: 'en' }); + +// Standalone function +const greeting$ = computed( + [userState, settingsState], + (user, settings) => `Hello, ${user.firstName} (${settings.locale})`, ); + +greeting$.subscribe((msg) => console.log(msg)); +// => "Hello, Jane (en)" + +// Also available as a method on Smartstate: +const greeting2$ = state.computed([userState, settingsState], (user, settings) => /* ... */); ``` -### Subscribing to State Changes +Computed observables are **lazy** — they only subscribe to sources when someone subscribes to them. -Subscribe to changes in a state part to perform actions accordingly: +### Batch Updates + +Update multiple state parts without intermediate notifications: ```typescript -// The select() method automatically filters out undefined states -userStatePart.select().subscribe((currentState) => { - console.log(`User Logged In: ${currentState.isLoggedIn}`); +const partA = await state.getStatePart('a', { value: 1 }); +const partB = await state.getStatePart('b', { value: 2 }); + +// Subscribers see no updates during the batch — only after it completes +await state.batch(async () => { + await partA.setState({ value: 10 }); + await partB.setState({ value: 20 }); + // Notifications are deferred here }); -``` +// Both subscribers now fire with their new values -Select a specific part of your state with a selector function: - -```typescript -userStatePart.select(state => state.username).subscribe((username) => { - if (username) { - console.log(`Current user: ${username}`); - } +// Nested batches are supported — flush happens at the outermost level +await state.batch(async () => { + await partA.setState({ value: 100 }); + await state.batch(async () => { + await partB.setState({ value: 200 }); + }); + // Still deferred }); +// Now both fire ``` -### Modifying State with Actions +### Waiting for State -Create actions to modify the state in a controlled manner: +Wait for a specific state condition to be met: ```typescript -interface ILoginPayload { - username: string; -} +// Wait for any truthy state +const currentState = await userState.waitUntilPresent(); -const loginUserAction = userStatePart.createAction(async (statePart, payload) => { - return { ...statePart.getState(), isLoggedIn: true, username: payload.username }; -}); +// Wait for a specific condition +const name = await userState.waitUntilPresent((s) => s.name || undefined); -// Dispatch the action to update the state -const newState = await loginUserAction.trigger({ username: 'johnDoe' }); -``` +// With timeout (backward compatible) +const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000); -### Dispatching Actions - -There are two ways to dispatch actions: - -```typescript -// Method 1: Using trigger on the action (returns promise) -const newState = await loginUserAction.trigger({ username: 'johnDoe' }); - -// Method 2: Using dispatchAction on the state part (returns promise) -const newState = await userStatePart.dispatchAction(loginUserAction, { username: 'johnDoe' }); -``` - -Both methods return a Promise with the new state payload. - -### Additional State Methods - -`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 state to be present -await userStatePart.waitUntilPresent(); - -// Wait for a specific property to be present -await userStatePart.waitUntilPresent(state => state.username); - -// Wait with a timeout (throws error if condition not met within timeout) +// With AbortSignal +const controller = new AbortController(); try { - await userStatePart.waitUntilPresent(state => state.username, 5000); // 5 second timeout -} catch (error) { - console.error('Timed out waiting for username'); + const name = await userState.waitUntilPresent( + (s) => s.name || undefined, + { timeoutMs: 5000, signal: controller.signal }, + ); +} catch (e) { + // 'Aborted' or timeout error } - -// Setup initial state with async operations -await userStatePart.stateSetup(async (statePart) => { - const userData = await fetchUserData(); - return { ...statePart.getState(), ...userData }; -}); - -// Defer notification to end of call stack (debounced) -userStatePart.notifyChangeCumulative(); ``` -### Persistent State with WebStore +### Context Protocol Bridge (Web Components) -`Smartstate` supports persistent states using WebStore (IndexedDB-based storage), allowing you to maintain state across sessions: +Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md): ```typescript -const settingsStatePart = await myAppSmartState.getStatePart( - AppStateParts.SettingsState, - { theme: 'light' }, // Initial/default state - 'persistent' // Mode +import { attachContextProvider } from '@push.rocks/smartstate'; + +// Define a context key +const themeContext = Symbol('theme'); + +// Attach a provider to a DOM element +const cleanup = attachContextProvider(myElement, { + context: themeContext, + statePart: settingsState, + selectorFn: (s) => s.theme, // optional: provide derived value +}); + +// Any descendant can request this context: +myElement.dispatchEvent( + new CustomEvent('context-request', { + bubbles: true, + composed: true, + detail: { + context: themeContext, + callback: (theme) => console.log('Theme:', theme), + subscribe: true, // receive updates on state changes + }, + }), ); + +// Cleanup when done +cleanup(); ``` -Persistent state automatically: -- Saves state changes to IndexedDB -- Restores state on application restart -- Merges persisted values with defaults (persisted values take precedence) -- Ensures atomic writes (persistence happens before memory update) +This works with Lit's `@consume()` decorator, FAST, or any framework implementing the Context Protocol. ### State Validation -`Smartstate` includes built-in state validation to ensure data integrity: +Built-in null/undefined validation. Extend for custom rules: ```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 { +class ValidatedPart extends StatePart { protected validateState(stateArg: any): stateArg is T { - return super.validateState(stateArg) && /* your validation */; + return super.validateState(stateArg) && typeof stateArg.name === 'string'; } } ``` -### Performance Optimization +### Performance Features -`Smartstate` includes advanced performance optimizations: +- **SHA256 Change Detection** — identical state values don't trigger notifications, even with different object references +- **Selector Memoization** — `select(fn)` caches observables by function reference, sharing one upstream subscription across all subscribers +- **Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification +- **Concurrent Safety** — simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation +- **Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency +- **Batch Deferred Notifications** — `batch()` suppresses all notifications until the batch completes -- **🔒 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()` with automatic debouncing -- **🎯 Selective Subscriptions**: Use selectors to subscribe only to specific state properties -- **✨ 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 +## API Reference -### RxJS Integration +### `Smartstate` -`Smartstate` leverages RxJS for reactive state management: +| Method | Description | +|--------|-------------| +| `getStatePart(name, initial?, initMode?)` | Get or create a state part | +| `batch(fn)` | Batch updates, defer notifications | +| `computed(sources, fn)` | Create computed observable | +| `isBatching` | Whether a batch is active | -```typescript -// State is exposed as an RxJS Subject -const stateObservable = userStatePart.select(); +### `StatePart` -// Automatically starts with current state value -stateObservable.subscribe((state) => { - console.log('Current state:', state); -}); +| Method | Description | +|--------|-------------| +| `getState()` | Get current state (or undefined) | +| `setState(newState)` | Set state (runs middleware, validates, persists, notifies) | +| `select(selectorFn?, options?)` | Subscribe to state changes | +| `createAction(actionDef)` | Create a named action | +| `dispatchAction(action, payload)` | Dispatch an action | +| `addMiddleware(fn)` | Add middleware, returns removal function | +| `waitUntilPresent(selectorFn?, options?)` | Wait for state condition | +| `notifyChange()` | Manually trigger notification | +| `notifyChangeCumulative()` | Debounced notification | +| `stateSetup(fn)` | Async state initialization | -// Use selectors for specific properties -userStatePart.select(state => state.username) - .pipe( - distinctUntilChanged(), - filter(username => username !== undefined) - ) - .subscribe(username => { - console.log('Username changed:', username); - }); -``` +### `StateAction` -### Complete Example +| Method | Description | +|--------|-------------| +| `trigger(payload)` | Dispatch the action | -Here's a comprehensive example showcasing the power of `@push.rocks/smartstate`: +### Standalone Functions -```typescript -import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate'; - -// 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 - -| Feature | Description | -|---------|-------------| -| 🎯 **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 | -| 🛡️ **Race condition safe** | Concurrent state part creation is handled safely | -| ⏱️ **Timeout support** | `waitUntilPresent` supports optional timeouts | +| Function | Description | +|----------|-------------| +| `computed(sources, fn)` | Create computed observable from state parts | +| `attachContextProvider(element, options)` | Bridge state to Context Protocol | ## License and Legal Information diff --git a/test/test.initialization.ts b/test/test.initialization.ts index 00d9ed4..f631128 100644 --- a/test/test.initialization.ts +++ b/test/test.initialization.ts @@ -10,10 +10,13 @@ interface ITestState { }; } +// ============================ +// Init mode tests +// ============================ + 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' } @@ -22,23 +25,20 @@ tap.test('should handle soft init mode (default)', async () => { value: 1, nested: { data: 'initial' } }); - - // Second call should return existing + const statePart2 = await state.getStatePart('initTest'); - expect(statePart1).toEqual(statePart2); + expect(statePart1 === statePart2).toBeTrue(); }); 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', { @@ -54,26 +54,24 @@ tap.test('should handle mandatory init mode', async () => { 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); + expect(statePart1 === statePart2).toBeFalse(); }); tap.test('should handle missing initial state error', async () => { const state = new smartstate.Smartstate(); - + let error: Error | null = null; try { await state.getStatePart('initTest'); @@ -86,13 +84,12 @@ tap.test('should handle missing initial state error', async () => { 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); @@ -106,20 +103,17 @@ tap.test('should handle state validation', async () => { 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, @@ -133,25 +127,506 @@ tap.test('should not notify on duplicate state', async () => { 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 +// ============================ +// AbortSignal tests +// ============================ + +tap.test('select should complete when AbortSignal fires', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }); + + const controller = new AbortController(); + const values: any[] = []; + let completed = false; + + statePart.select(undefined, { signal: controller.signal }).subscribe({ + next: (v) => values.push(v), + complete: () => { completed = true; }, + }); + + expect(values.length).toBeGreaterThanOrEqual(1); + + controller.abort(); + // Give microtask time + await new Promise((r) => setTimeout(r, 10)); + + expect(completed).toBeTrue(); +}); + +tap.test('select with pre-aborted signal should complete immediately', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }); + + const controller = new AbortController(); + controller.abort(); + + let completed = false; + statePart.select(undefined, { signal: controller.signal }).subscribe({ + complete: () => { completed = true; }, + }); + + await new Promise((r) => setTimeout(r, 10)); + expect(completed).toBeTrue(); +}); + +tap.test('waitUntilPresent should reject when AbortSignal fires', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 0, + nested: { data: '' } + }, 'force'); + + const controller = new AbortController(); + + const promise = statePart.waitUntilPresent( + (s) => s.value > 100 ? s : undefined as any, + { signal: controller.signal } + ); + + // Abort before the condition can be met + setTimeout(() => controller.abort(), 20); + + let error: Error | null = null; + try { + await promise; + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error?.message).toEqual('Aborted'); +}); + +tap.test('waitUntilPresent should still work with numeric timeout (backward compat)', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 42, + nested: { data: 'present' } + }, 'force'); + + const result = await statePart.waitUntilPresent(undefined, 5000); + expect(result.value).toEqual(42); +}); + +// ============================ +// Middleware tests +// ============================ + +tap.test('middleware should transform state', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }, 'force'); + + statePart.addMiddleware((newState, oldState) => { + return { ...newState, nested: { data: newState.nested.data.toUpperCase() } }; + }); + + await statePart.setState({ value: 2, nested: { data: 'hello' } }); + expect(statePart.getState().nested.data).toEqual('HELLO'); +}); + +tap.test('middleware should reject state changes on throw', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }, 'force'); + + statePart.addMiddleware((newState) => { + if (newState.value < 0) { + throw new Error('Value must be non-negative'); + } + return newState; + }); + + let error: Error | null = null; + try { + await statePart.setState({ value: -1, nested: { data: 'bad' } }); + } catch (e) { + error = e as Error; + } + + expect(error).not.toBeNull(); + expect(error?.message).toEqual('Value must be non-negative'); + // State should be unchanged + expect(statePart.getState().value).toEqual(1); +}); + +tap.test('multiple middlewares should run in order', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }, 'force'); + + const order: number[] = []; + + statePart.addMiddleware((newState) => { + order.push(1); + return { ...newState, value: newState.value + 10 }; + }); + + statePart.addMiddleware((newState) => { + order.push(2); + return { ...newState, value: newState.value * 2 }; + }); + + await statePart.setState({ value: 5, nested: { data: 'test' } }); + expect(order).toEqual([1, 2]); + // (5 + 10) * 2 = 30 + expect(statePart.getState().value).toEqual(30); +}); + +tap.test('middleware removal should work', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }, 'force'); + + const remove = statePart.addMiddleware((newState) => { + return { ...newState, value: newState.value * 100 }; + }); + + await statePart.setState({ value: 2, nested: { data: 'test' } }); + expect(statePart.getState().value).toEqual(200); + + remove(); + + await statePart.setState({ value: 3, nested: { data: 'test' } }); + expect(statePart.getState().value).toEqual(3); +}); + +// ============================ +// Selector memoization tests +// ============================ + +tap.test('select with same selector fn should return cached observable', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }, 'force'); + + const selector = (s: ITestState) => s.value; + const obs1 = statePart.select(selector); + const obs2 = statePart.select(selector); + expect(obs1).toEqual(obs2); +}); + +tap.test('select with no args should return cached observable', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }, 'force'); + + const obs1 = statePart.select(); + const obs2 = statePart.select(); + expect(obs1).toEqual(obs2); +}); + +tap.test('select with different selectors should return different observables', async () => { + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart('initTest', { + value: 1, + nested: { data: 'initial' } + }, 'force'); + + const obs1 = statePart.select((s) => s.value); + const obs2 = statePart.select((s) => s.nested); + expect(obs1).not.toEqual(obs2); +}); + +// ============================ +// Batch update tests +// ============================ + +tap.test('batch should defer notifications until complete', async () => { + type TBatchParts = 'partA' | 'partB'; + const state = new smartstate.Smartstate(); + const partA = await state.getStatePart('partA', { + value: 1, + nested: { data: 'a' } + }); + const partB = await state.getStatePart('partB', { + value: 2, + nested: { data: 'b' } + }); + + const notificationsA: number[] = []; + const notificationsB: number[] = []; + + partA.select((s) => s.value).subscribe((v) => notificationsA.push(v)); + partB.select((s) => s.value).subscribe((v) => notificationsB.push(v)); + + // Reset after initial notifications + notificationsA.length = 0; + notificationsB.length = 0; + + await state.batch(async () => { + await partA.setState({ value: 10, nested: { data: 'aa' } }); + await partB.setState({ value: 20, nested: { data: 'bb' } }); + + // During batch, no notifications yet + expect(notificationsA).toHaveLength(0); + expect(notificationsB).toHaveLength(0); + }); + + // After batch, both should have notified + expect(notificationsA).toContain(10); + expect(notificationsB).toContain(20); +}); + +tap.test('nested batches should only flush at outermost level', async () => { + type TBatchParts = 'nested'; + const state = new smartstate.Smartstate(); + const part = await state.getStatePart('nested', { + value: 0, + nested: { data: 'start' } + }); + + const values: number[] = []; + part.select((s) => s.value).subscribe((v) => values.push(v)); + values.length = 0; + + await state.batch(async () => { + await part.setState({ value: 1, nested: { data: 'a' } }); + + await state.batch(async () => { + await part.setState({ value: 2, nested: { data: 'b' } }); + // Still inside outer batch + expect(values).toHaveLength(0); + }); + + // Inner batch ended but outer batch still active + expect(values).toHaveLength(0); + }); + + // Now outer batch is done — should see final notification + expect(values.length).toBeGreaterThanOrEqual(1); + expect(values[values.length - 1]).toEqual(2); +}); + +// ============================ +// Computed state tests +// ============================ + +tap.test('computed should derive from multiple state parts', async () => { + type TComputedParts = 'first' | 'second'; + const state = new smartstate.Smartstate(); + const first = await state.getStatePart<{ count: number }>('first', { count: 5 }); + const second = await state.getStatePart<{ count: number }>('second', { count: 10 }); + + const derived$ = state.computed( + [first, second], + (a, b) => a.count + b.count, + ); + + const values: number[] = []; + derived$.subscribe((v) => values.push(v)); + + expect(values).toContain(15); +}); + +tap.test('computed should update when a source changes', async () => { + type TComputedParts = 'x' | 'y'; + const state = new smartstate.Smartstate(); + const x = await state.getStatePart<{ n: number }>('x', { n: 1 }); + const y = await state.getStatePart<{ n: number }>('y', { n: 2 }); + + const derived$ = state.computed( + [x, y], + (xState, yState) => xState.n * yState.n, + ); + + const values: number[] = []; + derived$.subscribe((v) => values.push(v)); + + // Initial: 1 * 2 = 2 + expect(values[0]).toEqual(2); + + await x.setState({ n: 5 }); + + // After update: 5 * 2 = 10 + expect(values[values.length - 1]).toEqual(10); +}); + +tap.test('standalone computed function should work', async () => { + type TParts = 'a' | 'b'; + const state = new smartstate.Smartstate(); + const a = await state.getStatePart<{ val: string }>('a', { val: 'hello' }); + const b = await state.getStatePart<{ val: string }>('b', { val: 'world' }); + + const derived$ = smartstate.computed( + [a, b], + (aState, bState) => `${aState.val} ${bState.val}`, + ); + + const values: string[] = []; + derived$.subscribe((v) => values.push(v)); + + expect(values[0]).toEqual('hello world'); + + await a.setState({ val: 'hi' }); + expect(values[values.length - 1]).toEqual('hi world'); +}); + +// ============================ +// Context Protocol tests +// ============================ + +tap.test('attachContextProvider should respond to context-request events', async () => { + // EventTarget and CustomEvent are available in Node 18+ + if (typeof EventTarget === 'undefined') { + console.log('Skipping context test — EventTarget not available'); + return; + } + + type TParts = 'ctx'; + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart<{ theme: string }>('ctx', { theme: 'dark' }); + + const myContext = Symbol('test-context'); + + // Use an EventTarget as a mock element + const element = new EventTarget() as any as HTMLElement; + + const cleanup = smartstate.attachContextProvider(element, { + context: myContext, + statePart, + }); + + let receivedValue: any = null; + + // Dispatch a context-request event + const event = new CustomEvent('context-request', { + detail: { + context: myContext, + callback: (value: any) => { receivedValue = value; }, + subscribe: false, + }, + bubbles: true, + composed: true, + }); + (element as any).dispatchEvent(event); + + expect(receivedValue).toEqual({ theme: 'dark' }); + + cleanup(); +}); + +tap.test('attachContextProvider should support subscriptions', async () => { + if (typeof EventTarget === 'undefined') { + console.log('Skipping context subscription test — EventTarget not available'); + return; + } + + type TParts = 'ctxSub'; + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart<{ count: number }>('ctxSub', { count: 0 }); + + const myContext = Symbol('sub-context'); + const element = new EventTarget() as any as HTMLElement; + + const cleanup = smartstate.attachContextProvider(element, { + context: myContext, + statePart, + }); + + const receivedValues: any[] = []; + let unsubFn: (() => void) | undefined; + + const event = new CustomEvent('context-request', { + detail: { + context: myContext, + callback: (value: any, unsub?: () => void) => { + receivedValues.push(value); + if (unsub) unsubFn = unsub; + }, + subscribe: true, + }, + bubbles: true, + composed: true, + }); + (element as any).dispatchEvent(event); + + expect(receivedValues).toHaveLength(1); + expect(receivedValues[0]).toEqual({ count: 0 }); + + // Update state — should trigger subscription callback + await statePart.setState({ count: 42 }); + + // Give a tick for the subscription to fire + await new Promise((r) => setTimeout(r, 10)); + + expect(receivedValues.length).toBeGreaterThanOrEqual(2); + expect(receivedValues[receivedValues.length - 1]).toEqual({ count: 42 }); + + // Unsubscribe + expect(unsubFn).toBeDefined(); + unsubFn!(); + + cleanup(); +}); + +tap.test('attachContextProvider should ignore non-matching contexts', async () => { + if (typeof EventTarget === 'undefined') { + console.log('Skipping context mismatch test — EventTarget not available'); + return; + } + + type TParts = 'ctxMismatch'; + const state = new smartstate.Smartstate(); + const statePart = await state.getStatePart<{ v: number }>('ctxMismatch', { v: 1 }); + + const myContext = Symbol('my-context'); + const otherContext = Symbol('other-context'); + const element = new EventTarget() as any as HTMLElement; + + const cleanup = smartstate.attachContextProvider(element, { + context: myContext, + statePart, + }); + + let called = false; + const event = new CustomEvent('context-request', { + detail: { + context: otherContext, + callback: () => { called = true; }, + subscribe: false, + }, + bubbles: true, + composed: true, + }); + (element as any).dispatchEvent(event); + + expect(called).toBeFalse(); + + cleanup(); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 8c58c76..cf466f9 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.31', - description: 'A package for handling and managing state in applications.' + version: '2.1.0', + description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.' } diff --git a/ts/index.ts b/ts/index.ts index 3d88693..2bbe9c9 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,3 +1,5 @@ export * from './smartstate.classes.smartstate.js'; export * from './smartstate.classes.statepart.js'; export * from './smartstate.classes.stateaction.js'; +export * from './smartstate.classes.computed.js'; +export * from './smartstate.contextprovider.js'; diff --git a/ts/smartstate.classes.computed.ts b/ts/smartstate.classes.computed.ts new file mode 100644 index 0000000..7da2379 --- /dev/null +++ b/ts/smartstate.classes.computed.ts @@ -0,0 +1,16 @@ +import * as plugins from './smartstate.plugins.js'; +import { combineLatest, map } from 'rxjs'; +import type { StatePart } from './smartstate.classes.statepart.js'; + +/** + * creates a computed observable derived from multiple state parts. + * the observable is lazy — it only subscribes to sources when subscribed to. + */ +export function computed( + sources: StatePart[], + computeFn: (...states: any[]) => TResult, +): plugins.smartrx.rxjs.Observable { + return combineLatest(sources.map((sp) => sp.select())).pipe( + map((states) => computeFn(...states)), + ) as plugins.smartrx.rxjs.Observable; +} diff --git a/ts/smartstate.classes.smartstate.ts b/ts/smartstate.classes.smartstate.ts index 279f1bf..712106d 100644 --- a/ts/smartstate.classes.smartstate.ts +++ b/ts/smartstate.classes.smartstate.ts @@ -1,5 +1,6 @@ import * as plugins from './smartstate.plugins.js'; import { StatePart } from './smartstate.classes.statepart.js'; +import { computed } from './smartstate.classes.computed.js'; export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent'; @@ -11,17 +12,57 @@ export class Smartstate { private pendingStatePartCreation: Map>> = new Map(); + // Batch support + private batchDepth = 0; + private pendingNotifications = new Set>(); + constructor() {} + /** + * whether state changes are currently being batched + */ + public get isBatching(): boolean { + return this.batchDepth > 0; + } + + /** + * registers a state part for deferred notification during a batch + */ + public registerPendingNotification(statePart: StatePart): void { + this.pendingNotifications.add(statePart); + } + + /** + * batches multiple state updates so subscribers are only notified once all updates complete + */ + public async batch(updateFn: () => Promise | void): Promise { + this.batchDepth++; + try { + await updateFn(); + } finally { + this.batchDepth--; + if (this.batchDepth === 0) { + const pending = [...this.pendingNotifications]; + this.pendingNotifications.clear(); + for (const sp of pending) { + await sp.notifyChange(); + } + } + } + } + + /** + * creates a computed observable derived from multiple state parts + */ + public computed( + sources: StatePart[], + computeFn: (...states: any[]) => TResult, + ): plugins.smartrx.rxjs.Observable { + return computed(sources, computeFn); + } + /** * Allows getting and initializing a new 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 */ public async getStatePart( statePartNameArg: StatePartNameType, @@ -43,16 +84,13 @@ export class Smartstate { `State part '${statePartNameArg}' already exists, but initMode is 'mandatory'` ); case 'force': - // Force mode: create new state part - break; // Fall through to creation + break; case 'soft': case 'persistent': default: - // Return existing state part return existingStatePart as StatePart; } } else { - // State part doesn't exist if (!initialArg) { throw new Error( `State part '${statePartNameArg}' does not exist and no initial state provided` @@ -73,9 +111,6 @@ export class Smartstate { /** * Creates a statepart - * @param statePartName - * @param initialPayloadArg - * @param initMode */ private async createStatePart( statePartName: StatePartNameType, @@ -91,21 +126,20 @@ export class Smartstate { } : null ); + newState.smartstateRef = this; await newState.init(); const currentState = newState.getState(); 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; } -} \ No newline at end of file +} diff --git a/ts/smartstate.classes.statepart.ts b/ts/smartstate.classes.statepart.ts index 039f242..01ff5cd 100644 --- a/ts/smartstate.classes.statepart.ts +++ b/ts/smartstate.classes.statepart.ts @@ -1,21 +1,54 @@ import * as plugins from './smartstate.plugins.js'; +import { Observable, shareReplay, takeUntil } from 'rxjs'; import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js'; +import type { Smartstate } from './smartstate.classes.smartstate.js'; + +export type TMiddleware = ( + newState: TPayload, + oldState: TPayload | undefined, +) => TPayload | Promise; + +/** + * creates an Observable that emits once when the given AbortSignal fires + */ +function fromAbortSignal(signal: AbortSignal): Observable { + return new Observable((subscriber) => { + if (signal.aborted) { + subscriber.next(); + subscriber.complete(); + return; + } + const handler = () => { + subscriber.next(); + subscriber.complete(); + }; + signal.addEventListener('abort', handler); + return () => signal.removeEventListener('abort', handler); + }); +} export class StatePart { public name: TStatePartName; public state = new plugins.smartrx.rxjs.Subject(); public stateStore: TStatePayload | undefined; + public smartstateRef?: Smartstate; private cumulativeDeferred = plugins.smartpromise.cumulativeDefer(); private pendingCumulativeNotification: ReturnType | null = null; + private pendingBatchNotification = false; private webStoreOptions: plugins.webstore.IWebStoreOptions; - private webStore: plugins.webstore.WebStore | null = null; // Add WebStore instance + private webStore: plugins.webstore.WebStore | null = null; + + private middlewares: TMiddleware[] = []; + + // Selector memoization + private selectorCache = new WeakMap>(); + private defaultSelectObservable: plugins.smartrx.rxjs.Observable | null = null; constructor(nameArg: TStatePartName, webStoreOptionsArg?: plugins.webstore.IWebStoreOptions) { this.name = nameArg; - // Initialize WebStore if webStoreOptions are provided if (webStoreOptionsArg) { this.webStoreOptions = webStoreOptionsArg; } @@ -43,23 +76,43 @@ export class StatePart { return this.stateStore; } + /** + * adds a middleware that intercepts setState calls. + * middleware can transform the state or throw to reject it. + * returns a removal function. + */ + public addMiddleware(middleware: TMiddleware): () => void { + this.middlewares.push(middleware); + return () => { + const idx = this.middlewares.indexOf(middleware); + if (idx !== -1) { + this.middlewares.splice(idx, 1); + } + }; + } + /** * sets the stateStore to the new state - * @param newStateArg */ public async setState(newStateArg: TStatePayload) { + // Run middleware chain + let processedState = newStateArg; + for (const mw of this.middlewares) { + processedState = await mw(processedState, this.stateStore); + } + // Validate state structure - if (!this.validateState(newStateArg)) { + if (!this.validateState(processedState)) { throw new Error(`Invalid state structure for state part '${this.name}'`); } - // Save to WebStore first to ensure atomicity - if save fails, memory state remains unchanged + // Save to WebStore first to ensure atomicity if (this.webStore) { - await this.webStore.set(String(this.name), newStateArg); + await this.webStore.set(String(this.name), processedState); } // Update in-memory state after successful persistence - this.stateStore = newStateArg; + this.stateStore = processedState; await this.notifyChange(); return this.stateStore; @@ -67,11 +120,8 @@ export class StatePart { /** * 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; } @@ -82,6 +132,14 @@ export class StatePart { if (!this.stateStore) { return; } + + // If inside a batch, defer the notification + if (this.smartstateRef?.isBatching) { + this.pendingBatchNotification = true; + this.smartstateRef.registerPendingNotification(this); + return; + } + const createStateHash = async (stateArg: any) => { return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg)); }; @@ -99,10 +157,9 @@ export class StatePart { private lastStateNotificationPayloadHash: any; /** - * 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() { - // Debounce: clear any pending notification if (this.pendingCumulativeNotification) { clearTimeout(this.pendingCumulativeNotification); } @@ -116,27 +173,56 @@ export class StatePart { } /** - * selects a state or a substate + * selects a state or a substate. + * supports an optional AbortSignal for automatic unsubscription. + * memoizes observables by selector function reference. */ public select( - selectorFn?: (state: TStatePayload) => T + selectorFn?: (state: TStatePayload) => T, + options?: { signal?: AbortSignal } ): plugins.smartrx.rxjs.Observable { - if (!selectorFn) { - selectorFn = (state: TStatePayload) => (state); + const hasSignal = options?.signal != null; + + // Check memoization cache (only for non-signal selects) + if (!hasSignal) { + if (!selectorFn) { + if (this.defaultSelectObservable) { + return this.defaultSelectObservable as unknown as plugins.smartrx.rxjs.Observable; + } + } else if (this.selectorCache.has(selectorFn)) { + return this.selectorCache.get(selectorFn)!; + } } - const mapped = this.state.pipe( + + const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => (state)); + + let 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); + return effectiveSelectorFn(stateArg); } catch (e) { console.error(`Selector error in state part '${this.name}':`, e); return undefined; } }) ); - return mapped; + + if (hasSignal) { + mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal))); + return mapped; + } + + // Apply shareReplay for caching and store in memo cache + const shared = mapped.pipe(shareReplay({ bufferSize: 1, refCount: true })); + if (!selectorFn) { + this.defaultSelectObservable = shared as unknown as plugins.smartrx.rxjs.Observable; + } else { + this.selectorCache.set(selectorFn, shared); + } + + return shared; } /** @@ -159,18 +245,32 @@ 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 + * waits until a certain part of the state becomes available. + * supports optional timeout and AbortSignal. */ public async waitUntilPresent( selectorFn?: (state: TStatePayload) => T, - timeoutMs?: number + optionsOrTimeout?: number | { timeoutMs?: number; signal?: AbortSignal } ): Promise { + // Parse backward-compatible args + let timeoutMs: number | undefined; + let signal: AbortSignal | undefined; + if (typeof optionsOrTimeout === 'number') { + timeoutMs = optionsOrTimeout; + } else if (optionsOrTimeout) { + timeoutMs = optionsOrTimeout.timeoutMs; + signal = optionsOrTimeout.signal; + } + const done = plugins.smartpromise.defer(); const selectedObservable = this.select(selectorFn); let resolved = false; + // Check if already aborted + if (signal?.aborted) { + throw new Error('Aborted'); + } + const subscription = selectedObservable.subscribe((value) => { if (value && !resolved) { resolved = true; @@ -189,12 +289,29 @@ export class StatePart { }, timeoutMs); } + // Handle abort signal + const abortHandler = signal ? () => { + if (!resolved) { + resolved = true; + subscription.unsubscribe(); + if (timeoutId) clearTimeout(timeoutId); + done.reject(new Error('Aborted')); + } + } : undefined; + + if (signal && abortHandler) { + signal.addEventListener('abort', abortHandler); + } + try { const result = await done.promise; return result; } finally { subscription.unsubscribe(); if (timeoutId) clearTimeout(timeoutId); + if (signal && abortHandler) { + signal.removeEventListener('abort', abortHandler); + } } } diff --git a/ts/smartstate.contextprovider.ts b/ts/smartstate.contextprovider.ts new file mode 100644 index 0000000..767da51 --- /dev/null +++ b/ts/smartstate.contextprovider.ts @@ -0,0 +1,61 @@ +import type { StatePart } from './smartstate.classes.statepart.js'; + +export interface IContextProviderOptions { + /** the context key (compared by strict equality) */ + context: unknown; + /** the state part to provide */ + statePart: StatePart; + /** optional selector to provide a derived value instead of the full state */ + selectorFn?: (state: TPayload) => any; +} + +/** + * attaches a Context Protocol provider to an HTML element. + * listens for `context-request` events and responds with the state part's value. + * if subscribe=true, retains the callback and invokes it on every state change. + * returns a cleanup function that removes the listener and unsubscribes. + */ +export function attachContextProvider( + element: HTMLElement, + options: IContextProviderOptions, +): () => void { + const { context, statePart, selectorFn } = options; + const subscribers = new Set<(value: any, unsubscribe?: () => void) => void>(); + + const subscription = statePart.select(selectorFn).subscribe((value) => { + for (const cb of subscribers) { + cb(value); + } + }); + + const getValue = (): any => { + const state = statePart.getState(); + if (state === undefined) return undefined; + return selectorFn ? selectorFn(state) : state; + }; + + const handler = (event: Event) => { + const e = event as CustomEvent; + const detail = e.detail; + if (!detail || detail.context !== context) return; + + e.stopPropagation(); + + if (detail.subscribe) { + const cb = detail.callback; + subscribers.add(cb); + const unsubscribe = () => subscribers.delete(cb); + cb(getValue(), unsubscribe); + } else { + detail.callback(getValue()); + } + }; + + element.addEventListener('context-request', handler); + + return () => { + element.removeEventListener('context-request', handler); + subscription.unsubscribe(); + subscribers.clear(); + }; +}