Compare commits

..

4 Commits

Author SHA1 Message Date
d45e1188b1 v2.1.1
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 1m21s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-28 08:52:41 +00:00
9312b8908c fix(core): serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling 2026-02-28 08:52:41 +00:00
2f0b39ae41 v2.1.0
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 1m20s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-27 11:40:07 +00:00
575477df09 feat(smartstate): Add middleware, computed, batching, selector memoization, AbortSignal support, and Web Component Context Protocol provider 2026-02-27 11:40:07 +00:00
15 changed files with 1386 additions and 376 deletions

View File

@@ -1,5 +1,29 @@
# Changelog # Changelog
## 2026-02-28 - 2.1.1 - fix(core)
serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling
- Serialize setState() and dispatchAction() using an internal mutation queue to prevent lost updates and race conditions.
- Prevent batch flush deadlocks by introducing isFlushing and draining pending notifications iteratively.
- Force initMode now disposes the previous StatePart so the Subject completes and resources are cleaned up.
- Treat falsy but non-null values (0, false) as present: getStatePart accepts 0 as initial value and waitUntilPresent resolves for false/0.
- Improve notifyChange: use a stable snapshot, catch and log hash computation errors, and avoid duplicate notifications; notifyChangeCumulative now safely catches async errors.
- Add StatePart.dispose() to complete the Subject and clear pending timers/middlewares.
- Add/adjust tests for concurrent dispatches, concurrent setState, disposal behavior, falsy state handling, batch re-entrancy, force-mode disposal, and zero initialization.
- Documentation and README improvements (examples, clearer descriptions, persistence notes) and minor code cleanup (remove unused import).
## 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) ## 2026-02-27 - 2.0.31 - fix(deps)
bump devDependencies and fix README license path bump devDependencies and fix README license path

View File

@@ -16,7 +16,7 @@
"githost": "code.foss.global", "githost": "code.foss.global",
"gitscope": "push.rocks", "gitscope": "push.rocks",
"gitrepo": "smartstate", "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", "npmPackagename": "@push.rocks/smartstate",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [
@@ -29,7 +29,13 @@
"state selection", "state selection",
"state notification", "state notification",
"asynchronous state", "asynchronous state",
"cumulative notification" "cumulative notification",
"middleware",
"computed state",
"batch updates",
"context protocol",
"web components",
"AbortSignal"
] ]
}, },
"release": { "release": {

View File

@@ -1,8 +1,8 @@
{ {
"name": "@push.rocks/smartstate", "name": "@push.rocks/smartstate",
"version": "2.0.31", "version": "2.1.1",
"private": false, "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", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
"type": "module", "type": "module",
@@ -22,7 +22,6 @@
"@types/node": "^25.3.2" "@types/node": "^25.3.2"
}, },
"dependencies": { "dependencies": {
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smarthash": "^3.2.6", "@push.rocks/smarthash": "^3.2.6",
"@push.rocks/smartjson": "^6.0.0", "@push.rocks/smartjson": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
@@ -54,7 +53,13 @@
"state selection", "state selection",
"state notification", "state notification",
"asynchronous state", "asynchronous state",
"cumulative notification" "cumulative notification",
"middleware",
"computed state",
"batch updates",
"context protocol",
"web components",
"AbortSignal"
], ],
"homepage": "https://code.foss.global/push.rocks/smartstate", "homepage": "https://code.foss.global/push.rocks/smartstate",
"repository": { "repository": {

3
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.: .:
dependencies: dependencies:
'@push.rocks/lik':
specifier: ^6.2.2
version: 6.2.2
'@push.rocks/smarthash': '@push.rocks/smarthash':
specifier: ^3.2.6 specifier: ^3.2.6
version: 3.2.6 version: 3.2.6

View File

@@ -1,6 +1,6 @@
# Smartstate Implementation Notes # Smartstate Implementation Notes
## Current API (as of v2.0.28+) ## Current API (as of v2.0.31)
### State Part Initialization ### State Part Initialization
- State parts can be created with different init modes: 'soft' (default), 'mandatory', 'force', 'persistent' - 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 - 'mandatory' - requires state part to not exist, fails if it does
- 'force' - always creates new state part, overwriting any existing - 'force' - always creates new state part, overwriting any existing
- 'persistent' - like 'soft' but with WebStore persistence (IndexedDB) - '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 - State merge order fixed: initial state takes precedence over stored state
### Actions ### Actions
- Actions are created with `createAction()` method - Actions are created with `createAction()` method
- Two ways to dispatch actions: - Two ways to dispatch: `stateAction.trigger(payload)` or `statePart.dispatchAction(stateAction, payload)`
1. `stateAction.trigger(payload)` - returns Promise<TStatePayload> - Both return Promise<TStatePayload>
2. `await statePart.dispatchAction(stateAction, payload)` - returns Promise<TStatePayload>
- Both methods return the same Promise, providing flexibility in usage
### State Management Methods ### State Management Methods
- `select()` - returns Observable with startWith current state, filters undefined states - `select(fn?, { signal? })` - returns Observable, memoized by selector fn ref, supports AbortSignal
- `waitUntilPresent()` - waits for specific state condition - `waitUntilPresent(fn?, number | { timeoutMs?, signal? })` - waits for state condition, backward compat with number arg
- `stateSetup()` - async state initialization with cumulative defer - `stateSetup()` - async state initialization with cumulative defer
- `notifyChangeCumulative()` - defers notification to end of call stack - `notifyChangeCumulative()` - defers notification to end of call stack
- `getState()` - returns current state or undefined - `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<newState>`
- 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<Function, Observable> 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 ### State Hash Detection
- Uses SHA256 hash to detect actual state changes - 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 - Prevents duplicate notifications for identical state values
- `notifyChange()` is now async to support proper hash comparison
### State Validation ### State Validation
- Basic validation ensures state is not null/undefined - Basic validation ensures state is not null/undefined
- `validateState()` method can be overridden in subclasses for custom validation - `validateState()` can be overridden in subclasses
- Validation runs on both setState() and when loading from persistent storage
### Type System ### Key Notes
- Can use either enums or string literal types for state part names - `smartstateRef` creates circular ref between StatePart and Smartstate
- Test uses simple string types: `type TMyStateParts = 'testStatePart'` - Use `===` not deep equality for StatePart comparison in tests
- State can be undefined initially, handled properly in select() and other methods - Direct rxjs imports used for: Observable, shareReplay, takeUntil, combineLatest, map
## Recent Fixes (v2.0.24+) ## Dependency Versions (v2.0.31)
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)
- @git.zone/tsbuild: ^4.1.2 - @git.zone/tsbuild: ^4.1.2
- @git.zone/tsbundle: ^2.9.0 - @git.zone/tsbundle: ^2.9.0
- @git.zone/tsrun: ^2.0.1 - @git.zone/tsrun: ^2.0.1
- @git.zone/tstest: ^3.1.8 - @git.zone/tstest: ^3.1.8
- @push.rocks/smartjson: ^6.0.0 - @push.rocks/smartjson: ^6.0.0
- @types/node: ^25.3.2 - @types/node: ^25.3.2

551
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartstate # @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 ## Issue Reporting and Security
@@ -8,310 +8,383 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
## Install ## Install
To install `@push.rocks/smartstate`, you can use pnpm, npm, or yarn: ```bash
pnpm install @push.rocks/smartstate --save
```
Or with npm:
```bash ```bash
# Using pnpm (recommended)
pnpm install @push.rocks/smartstate --save
# Using npm
npm install @push.rocks/smartstate --save npm install @push.rocks/smartstate --save
# Using yarn
yarn add @push.rocks/smartstate
``` ```
## Usage ## 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. ### Quick Start
### Getting Started
Import the necessary components from the library:
```typescript ```typescript
import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate'; import { Smartstate } from '@push.rocks/smartstate';
```
### Creating a SmartState Instance // 1. Define your state part names
type AppParts = 'user' | 'settings';
`Smartstate` acts as the container for your state parts. Think of it as the root of your state management structure: // 2. Create the root instance
const state = new Smartstate<AppParts>();
```typescript // 3. Create state parts with initial values
const myAppSmartState = new Smartstate<YourStatePartNamesEnum>(); const userState = await state.getStatePart<{ name: string; loggedIn: boolean }>('user', {
``` name: '',
loggedIn: false,
### Understanding Init Modes
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:
```typescript
// Option 1: Using enums
enum AppStateParts {
UserState = 'UserState',
SettingsState = 'SettingsState'
}
// Option 2: Using string literal types (simpler approach)
type AppStateParts = 'UserState' | 'SettingsState';
```
Create a state part within your `Smartstate` instance:
```typescript
interface IUserState {
isLoggedIn: boolean;
username?: string;
}
const userStatePart = await myAppSmartState.getStatePart<IUserState>(
AppStateParts.UserState,
{ isLoggedIn: false }, // Initial state
'soft' // Init mode (optional, defaults to 'soft')
);
```
### Subscribing to State Changes
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}`);
}); });
// 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 });
``` ```
Select a specific part of your state with a selector function: ### 🧩 State Parts & Init Modes
State parts are isolated, typed units of state. They are the building blocks of your application's state tree. Create them via `getStatePart()`:
```typescript ```typescript
userStatePart.select(state => state.username).subscribe((username) => { const part = await state.getStatePart<IMyState>(name, initialState, initMode);
if (username) {
console.log(`Current user: ${username}`);
}
});
``` ```
### Modifying State with Actions | Init Mode | Behavior |
|-----------|----------|
| `'soft'` (default) | Returns existing if found, creates new otherwise |
| `'mandatory'` | Throws if state part already exists — useful for ensuring single-initialization |
| `'force'` | Always creates a new state part, overwriting any existing one |
| `'persistent'` | Like `'soft'` but automatically persists state to IndexedDB via WebStore |
Create actions to modify the state in a controlled manner: You can use either enums or string literal types for state part names:
```typescript
// String literal types (simpler)
type AppParts = 'user' | 'settings' | 'cart';
// Enums (more explicit)
enum AppParts {
User = 'user',
Settings = 'settings',
Cart = 'cart',
}
```
#### 💾 Persistent State
```typescript
const settings = await state.getStatePart('settings', { theme: 'dark', fontSize: 14 }, 'persistent');
// ✅ Automatically saved to IndexedDB on every setState()
// ✅ On next app load, persisted values override defaults
// ✅ Persistence writes complete before in-memory updates (atomic)
```
### 🔭 Selecting State
`select()` returns an RxJS Observable that emits the current value immediately and on every subsequent change:
```typescript
// Full state
userState.select().subscribe((state) => console.log(state));
// Derived value via selector function
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 via `shareReplay`. This means you can call `select(mySelector)` in multiple places without creating duplicate subscriptions.
#### ✂️ AbortSignal Support
Clean up subscriptions without manual `.unsubscribe()` — the modern way:
```typescript
const controller = new AbortController();
userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => {
console.log(name); // automatically stops receiving when aborted
});
// Later: clean up all subscriptions tied to this signal
controller.abort();
```
### ⚡ Actions
Actions provide controlled, named state mutations with full async support:
```typescript ```typescript
interface ILoginPayload { interface ILoginPayload {
username: string; username: string;
email: string;
} }
const loginUserAction = userStatePart.createAction<ILoginPayload>(async (statePart, payload) => { const loginAction = userState.createAction<ILoginPayload>(async (statePart, payload) => {
return { ...statePart.getState(), isLoggedIn: true, username: payload.username }; // You have access to the current state via statePart.getState()
const current = statePart.getState();
return { ...current, name: payload.username, loggedIn: true };
}); });
// Dispatch the action to update the state // Two equivalent ways to dispatch:
const newState = await loginUserAction.trigger({ username: 'johnDoe' }); await loginAction.trigger({ username: 'Alice', email: 'alice@example.com' });
// or
await userState.dispatchAction(loginAction, { username: 'Alice', email: 'alice@example.com' });
``` ```
### Dispatching Actions Both `trigger()` and `dispatchAction()` return a Promise with the new state.
There are two ways to dispatch actions: ### 🛡️ Middleware
Intercept every `setState()` call to transform, validate, log, or reject state changes:
```typescript ```typescript
// Method 1: Using trigger on the action (returns promise) // Logging middleware
const newState = await loginUserAction.trigger({ username: 'johnDoe' }); userState.addMiddleware((newState, oldState) => {
console.log('State changing:', oldState, '→', newState);
return newState;
});
// Method 2: Using dispatchAction on the state part (returns promise) // Validation middleware — throw to reject the change
const newState = await userStatePart.dispatchAction(loginUserAction, { username: 'johnDoe' }); 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() };
});
// Async middleware
userState.addMiddleware(async (newState, oldState) => {
await auditLog('state-change', { from: oldState, to: newState });
return newState;
});
// Removal — addMiddleware() returns a dispose function
const remove = userState.addMiddleware(myMiddleware);
remove(); // middleware no longer runs
``` ```
Both methods return a Promise with the new state payload. Middleware runs **sequentially** in insertion order. If any middleware throws, the state remains unchanged — the operation is **atomic**.
### Additional State Methods ### 🧮 Computed / Derived State
`StatePart` provides several useful methods for state management: Derive reactive values from one or more state parts using `combineLatest` under the hood:
```typescript ```typescript
// Get current state (may be undefined initially) import { computed } from '@push.rocks/smartstate';
const currentState = userStatePart.getState();
if (currentState) {
console.log('Current user:', currentState.username);
}
// Wait for state to be present const userState = await state.getStatePart('user', { firstName: 'Jane', lastName: 'Doe' });
await userStatePart.waitUntilPresent(); const settingsState = await state.getStatePart('settings', { locale: 'en' });
// Wait for a specific property to be present // Standalone function
await userStatePart.waitUntilPresent(state => state.username); const greeting$ = computed(
[userState, settingsState],
(user, settings) => `Hello, ${user.firstName} (${settings.locale})`,
);
// Wait with a timeout (throws error if condition not met within timeout) greeting$.subscribe((msg) => console.log(msg));
// => "Hello, Jane (en)"
// Also available as a convenience method on the Smartstate instance:
const greeting2$ = state.computed(
[userState, settingsState],
(user, settings) => `${user.firstName} - ${settings.locale}`,
);
```
Computed observables are **lazy** — they only subscribe to their sources when someone subscribes to them, and they automatically unsubscribe when all subscribers disconnect.
### 📦 Batch Updates
Update multiple state parts at once while deferring all notifications until the entire batch completes:
```typescript
const partA = await state.getStatePart('a', { value: 1 });
const partB = await state.getStatePart('b', { value: 2 });
await state.batch(async () => {
await partA.setState({ value: 10 });
await partB.setState({ value: 20 });
// No notifications fire inside the batch
});
// Both subscribers now fire with their new values simultaneously
// Nested batches are supported — flush happens at the outermost level only
await state.batch(async () => {
await partA.setState({ value: 100 });
await state.batch(async () => {
await partB.setState({ value: 200 });
});
// Still deferred — inner batch doesn't trigger flush
});
// Now both fire
```
### ⏳ Waiting for State
Wait for a specific state condition to be met before proceeding:
```typescript
// Wait for any truthy state
const currentState = await userState.waitUntilPresent();
// Wait for a specific condition
const name = await userState.waitUntilPresent((s) => s.name || undefined);
// With timeout (milliseconds)
const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
// With AbortSignal and/or timeout via options object
const controller = new AbortController();
try { try {
await userStatePart.waitUntilPresent(state => state.username, 5000); // 5 second timeout const name = await userState.waitUntilPresent(
} catch (error) { (s) => s.name || undefined,
console.error('Timed out waiting for username'); { timeoutMs: 5000, signal: controller.signal },
);
} catch (e) {
// e.message is 'Aborted' or 'waitUntilPresent timed out after 5000ms'
} }
```
// Setup initial state with async operations ### 🌐 Context Protocol Bridge (Web Components)
await userStatePart.stateSetup(async (statePart) => {
const userData = await fetchUserData(); Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md). This lets any web component framework (Lit, FAST, Stencil, or vanilla) consume your state without coupling:
```typescript
import { attachContextProvider } from '@push.rocks/smartstate';
// Define a context key (use Symbol for uniqueness)
const themeContext = Symbol('theme');
// Attach a provider to a DOM element — any descendant can consume it
const cleanup = attachContextProvider(document.body, {
context: themeContext,
statePart: settingsState,
selectorFn: (s) => s.theme, // optional: provide a derived value instead of full state
});
// A consumer dispatches a context-request event:
myComponent.dispatchEvent(
new CustomEvent('context-request', {
bubbles: true,
composed: true,
detail: {
context: themeContext,
callback: (theme) => console.log('Got theme:', theme),
subscribe: true, // receive updates whenever the state changes
},
}),
);
// Works seamlessly with Lit's @consume() decorator, FAST's context, etc.
// Cleanup when the provider is no longer needed
cleanup();
```
### ✅ State Validation
Built-in validation prevents `null` and `undefined` from being set as state. For custom validation, extend `StatePart`:
```typescript
import { StatePart } from '@push.rocks/smartstate';
class ValidatedUserPart extends StatePart<string, IUserState> {
protected validateState(stateArg: any): stateArg is IUserState {
return (
super.validateState(stateArg) &&
typeof stateArg.name === 'string' &&
typeof stateArg.loggedIn === 'boolean'
);
}
}
```
If validation fails, `setState()` throws and the state remains unchanged.
### ⚙️ Async State Setup
Initialize state with async operations while ensuring actions wait for setup to complete:
```typescript
await userState.stateSetup(async (statePart) => {
const userData = await fetchUserFromAPI();
return { ...statePart.getState(), ...userData }; return { ...statePart.getState(), ...userData };
}); });
// Defer notification to end of call stack (debounced) // Any dispatchAction() calls will automatically wait for stateSetup() to finish
userStatePart.notifyChangeCumulative();
``` ```
### Persistent State with WebStore ### 🏎️ Performance
`Smartstate` supports persistent states using WebStore (IndexedDB-based storage), allowing you to maintain state across sessions: Smartstate is built with performance in mind:
```typescript - **🔒 SHA256 Change Detection** — Uses content hashing to detect actual changes. Identical state values don't trigger notifications, even with different object references.
const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>( - **♻️ Selector Memoization** — `select(fn)` caches observables by function reference and shares them via `shareReplay({ refCount: true })`. Multiple subscribers share one upstream subscription.
AppStateParts.SettingsState, - **📦 Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification at the end of the call stack.
{ theme: 'light' }, // Initial/default state - **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation or race conditions.
'persistent' // Mode - **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency even if the process crashes mid-write.
); - **⏸️ Batch Deferred Notifications** — `batch()` suppresses all subscriber notifications until every update in the batch completes.
```
Persistent state automatically: ## API Reference
- 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)
### State Validation ### `Smartstate<T>`
`Smartstate` includes built-in state validation to ensure data integrity: | Method / Property | Description |
|-------------------|-------------|
| `getStatePart(name, initial?, initMode?)` | Get or create a typed state part |
| `batch(fn)` | Batch state updates, defer all notifications until complete |
| `computed(sources, fn)` | Create a computed observable from multiple state parts |
| `isBatching` | `boolean` — whether a batch is currently active |
| `statePartMap` | Registry of all created state parts |
```typescript ### `StatePart<TName, TPayload>`
// 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 | Method | Description |
class ValidatedStatePart<T> extends StatePart<string, T> { |--------|-------------|
protected validateState(stateArg: any): stateArg is T { | `getState()` | Get current state (returns `TPayload \| undefined`) |
return super.validateState(stateArg) && /* your validation */; | `setState(newState)` | Set state — runs middleware → validates → persists → notifies |
} | `select(selectorFn?, options?)` | Returns an Observable of state or derived values. Options: `{ signal?: AbortSignal }` |
} | `createAction(actionDef)` | Create a reusable, typed state action |
``` | `dispatchAction(action, payload)` | Dispatch an action and return the new state |
| `addMiddleware(fn)` | Add a middleware interceptor. Returns a removal function |
| `waitUntilPresent(selectorFn?, opts?)` | Wait for a state condition. Opts: `number` (timeout) or `{ timeoutMs?, signal? }` |
| `notifyChange()` | Manually trigger a change notification (with hash dedup) |
| `notifyChangeCumulative()` | Debounced notification — fires at end of call stack |
| `stateSetup(fn)` | Async state initialization with action serialization |
### Performance Optimization ### `StateAction<TState, TPayload>`
`Smartstate` includes advanced performance optimizations: | Method | Description |
|--------|-------------|
| `trigger(payload)` | Dispatch the action on its associated state part |
- **🔒 Async State Hash Detection**: Uses SHA256 hashing to detect actual state changes, preventing unnecessary notifications when state values haven't truly changed ### Standalone Functions
- **🚫 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
### RxJS Integration | Function | Description |
|----------|-------------|
| `computed(sources, fn)` | Create a computed observable from multiple state parts |
| `attachContextProvider(element, options)` | Bridge a state part to the W3C Context Protocol |
`Smartstate` leverages RxJS for reactive state management: ### Exported Types
```typescript | Type | Description |
// State is exposed as an RxJS Subject |------|-------------|
const stateObservable = userStatePart.select(); | `TInitMode` | `'soft' \| 'mandatory' \| 'force' \| 'persistent'` |
| `TMiddleware<TPayload>` | `(newState, oldState) => TPayload \| Promise<TPayload>` |
// Automatically starts with current state value | `IActionDef<TState, TPayload>` | Action definition function signature |
stateObservable.subscribe((state) => { | `IContextProviderOptions<TPayload>` | Options for `attachContextProvider` |
console.log('Current state:', state);
});
// Use selectors for specific properties
userStatePart.select(state => state.username)
.pipe(
distinctUntilChanged(),
filter(username => username !== undefined)
)
.subscribe(username => {
console.log('Username changed:', username);
});
```
### Complete Example
Here's a comprehensive example showcasing the power of `@push.rocks/smartstate`:
```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<AppStateParts>();
// Initialize state parts
const userState = await appState.getStatePart<IUserState>('user', {
isLoggedIn: false
});
const cartState = await appState.getStatePart<ICartState>('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 |
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**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. **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.
@@ -323,7 +396,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information ### Company Information
Task Venture Capital GmbH Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc. For any legal inquiries or further information, please contact us via email at hello@task.vc.

View File

@@ -10,10 +10,13 @@ interface ITestState {
}; };
} }
// ============================
// Init mode tests
// ============================
tap.test('should handle soft init mode (default)', async () => { tap.test('should handle soft init mode (default)', async () => {
const state = new smartstate.Smartstate<TTestStateParts>(); const state = new smartstate.Smartstate<TTestStateParts>();
// First creation
const statePart1 = await state.getStatePart<ITestState>('initTest', { const statePart1 = await state.getStatePart<ITestState>('initTest', {
value: 1, value: 1,
nested: { data: 'initial' } nested: { data: 'initial' }
@@ -22,23 +25,20 @@ tap.test('should handle soft init mode (default)', async () => {
value: 1, value: 1,
nested: { data: 'initial' } nested: { data: 'initial' }
}); });
// Second call should return existing
const statePart2 = await state.getStatePart<ITestState>('initTest'); const statePart2 = await state.getStatePart<ITestState>('initTest');
expect(statePart1).toEqual(statePart2); expect(statePart1 === statePart2).toBeTrue();
}); });
tap.test('should handle mandatory init mode', async () => { tap.test('should handle mandatory init mode', async () => {
const state = new smartstate.Smartstate<TTestStateParts>(); const state = new smartstate.Smartstate<TTestStateParts>();
// First creation should succeed
const statePart1 = await state.getStatePart<ITestState>('initTest', { const statePart1 = await state.getStatePart<ITestState>('initTest', {
value: 1, value: 1,
nested: { data: 'initial' } nested: { data: 'initial' }
}, 'mandatory'); }, 'mandatory');
expect(statePart1).toBeInstanceOf(smartstate.StatePart); expect(statePart1).toBeInstanceOf(smartstate.StatePart);
// Second call with mandatory should fail
let error: Error | null = null; let error: Error | null = null;
try { try {
await state.getStatePart<ITestState>('initTest', { await state.getStatePart<ITestState>('initTest', {
@@ -54,26 +54,24 @@ tap.test('should handle mandatory init mode', async () => {
tap.test('should handle force init mode', async () => { tap.test('should handle force init mode', async () => {
const state = new smartstate.Smartstate<TTestStateParts>(); const state = new smartstate.Smartstate<TTestStateParts>();
// First creation
const statePart1 = await state.getStatePart<ITestState>('forceTest', { const statePart1 = await state.getStatePart<ITestState>('forceTest', {
value: 1, value: 1,
nested: { data: 'initial' } nested: { data: 'initial' }
}); });
expect(statePart1.getState()?.value).toEqual(1); expect(statePart1.getState()?.value).toEqual(1);
// Force should create new state part
const statePart2 = await state.getStatePart<ITestState>('forceTest', { const statePart2 = await state.getStatePart<ITestState>('forceTest', {
value: 2, value: 2,
nested: { data: 'forced' } nested: { data: 'forced' }
}, 'force'); }, 'force');
expect(statePart2.getState()?.value).toEqual(2); expect(statePart2.getState()?.value).toEqual(2);
expect(statePart1).not.toEqual(statePart2); expect(statePart1 === statePart2).toBeFalse();
}); });
tap.test('should handle missing initial state error', async () => { tap.test('should handle missing initial state error', async () => {
const state = new smartstate.Smartstate<TTestStateParts>(); const state = new smartstate.Smartstate<TTestStateParts>();
let error: Error | null = null; let error: Error | null = null;
try { try {
await state.getStatePart<ITestState>('initTest'); await state.getStatePart<ITestState>('initTest');
@@ -86,13 +84,12 @@ tap.test('should handle missing initial state error', async () => {
tap.test('should handle state validation', async () => { tap.test('should handle state validation', async () => {
const state = new smartstate.Smartstate<TTestStateParts>(); const state = new smartstate.Smartstate<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('initTest', { const statePart = await state.getStatePart<ITestState>('initTest', {
value: 1, value: 1,
nested: { data: 'initial' } nested: { data: 'initial' }
}); });
// Setting null should fail validation
let error: Error | null = null; let error: Error | null = null;
try { try {
await statePart.setState(null as any); 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 () => { tap.test('should handle undefined state in select', async () => {
const state = new smartstate.Smartstate<TTestStateParts>(); const state = new smartstate.Smartstate<TTestStateParts>();
const statePart = new smartstate.StatePart<TTestStateParts, ITestState>('initTest'); const statePart = new smartstate.StatePart<TTestStateParts, ITestState>('initTest');
// Select should filter out undefined states
const values: (ITestState | undefined)[] = []; const values: (ITestState | undefined)[] = [];
statePart.select().subscribe(val => values.push(val)); statePart.select().subscribe(val => values.push(val));
// Initially undefined, should not emit
expect(values).toHaveLength(0); expect(values).toHaveLength(0);
// After setting state, should emit
await statePart.setState({ await statePart.setState({
value: 1, value: 1,
nested: { data: 'test' } nested: { data: 'test' }
}); });
expect(values).toHaveLength(1); expect(values).toHaveLength(1);
expect(values[0]).toEqual({ expect(values[0]).toEqual({
value: 1, value: 1,
@@ -133,25 +127,641 @@ tap.test('should not notify on duplicate state', async () => {
value: 1, value: 1,
nested: { data: 'initial' } nested: { data: 'initial' }
}); });
let notificationCount = 0; let notificationCount = 0;
// Use select() to get initial value + changes
statePart.select().subscribe(() => notificationCount++); statePart.select().subscribe(() => notificationCount++);
// Should have received initial state
expect(notificationCount).toEqual(1); 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' } }); 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); expect(notificationCount).toEqual(1);
// Change state should notify
await statePart.setState({ value: 2, nested: { data: 'changed' } }); await statePart.setState({ value: 2, nested: { data: 'changed' } });
expect(notificationCount).toEqual(2); expect(notificationCount).toEqual(2);
}); });
export default tap.start(); // ============================
// AbortSignal tests
// ============================
tap.test('select should complete when AbortSignal fires', async () => {
const state = new smartstate.Smartstate<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<void>((r) => setTimeout(r, 10));
expect(completed).toBeTrue();
});
tap.test('select with pre-aborted signal should complete immediately', async () => {
const state = new smartstate.Smartstate<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<void>((r) => setTimeout(r, 10));
expect(completed).toBeTrue();
});
tap.test('waitUntilPresent should reject when AbortSignal fires', async () => {
const state = new smartstate.Smartstate<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TTestStateParts>();
const statePart = await state.getStatePart<ITestState>('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<TBatchParts>();
const partA = await state.getStatePart<ITestState>('partA', {
value: 1,
nested: { data: 'a' }
});
const partB = await state.getStatePart<ITestState>('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<TBatchParts>();
const part = await state.getStatePart<ITestState>('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<TComputedParts>();
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<TComputedParts>();
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<TParts>();
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<TParts>();
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<TParts>();
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<void>((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<TParts>();
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();
});
// ============================
// Enterprise hardening tests
// ============================
tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10)', async () => {
type TParts = 'counter';
const state = new smartstate.Smartstate<TParts>();
const counter = await state.getStatePart<{ count: number }>('counter', { count: 0 });
const increment = counter.createAction<void>(async (statePart) => {
const current = statePart.getState();
return { count: current.count + 1 };
});
// Fire 10 concurrent increments (no await)
const promises: Promise<any>[] = [];
for (let i = 0; i < 10; i++) {
promises.push(counter.dispatchAction(increment, undefined));
}
await Promise.all(promises);
expect(counter.getState().count).toEqual(10);
});
tap.test('concurrent setState should serialize (no lost updates)', async () => {
type TParts = 'concurrent';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ values: number[] }>('concurrent', { values: [] });
const promises: Promise<any>[] = [];
for (let i = 0; i < 5; i++) {
promises.push(
part.setState({ values: [...(part.getState()?.values || []), i] })
);
}
await Promise.all(promises);
// At minimum, the final state should have been set 5 times without error
// The exact values depend on serialization timing, but state should be valid
expect(part.getState()).toBeTruthy();
expect(part.getState().values).toBeInstanceOf(Array);
});
tap.test('dispose should complete the Subject and notify subscribers', async () => {
type TParts = 'disposable';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ v: number }>('disposable', { v: 1 });
let completed = false;
part.select().subscribe({
complete: () => { completed = true; },
});
part.dispose();
expect(completed).toBeTrue();
});
tap.test('falsy state {count: 0} should trigger notification', async () => {
type TParts = 'falsy';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ count: number }>('falsy', { count: 0 });
const values: number[] = [];
part.select((s) => s.count).subscribe((v) => values.push(v));
// Initial value should include 0
expect(values).toContain(0);
await part.setState({ count: 0 });
// Should not duplicate since hash is the same, but the initial notification should have fired
expect(values.length).toBeGreaterThanOrEqual(1);
});
tap.test('waitUntilPresent should resolve for falsy non-null values like false', async () => {
type TParts = 'falsyWait';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ flag: boolean }>('falsyWait', { flag: false });
const result = await part.waitUntilPresent((s) => s.flag as any, 2000);
// false is not null/undefined, so it should resolve
// Actually false IS falsy for `value !== undefined && value !== null` — false passes that check
expect(result).toBeFalse();
});
tap.test('batch re-entrancy: setState during flush should not deadlock', async () => {
type TParts = 'reentrant';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ v: number }>('reentrant', { v: 0 });
let flushSetStateDone = false;
// Subscribe and trigger a setState during notification flush
part.select((s) => s.v).subscribe((v) => {
if (v === 1 && !flushSetStateDone) {
flushSetStateDone = true;
// Fire-and-forget setState during notification — should not deadlock
part.setState({ v: 2 });
}
});
await state.batch(async () => {
await part.setState({ v: 1 });
});
// Wait for the fire-and-forget setState to complete
await new Promise<void>((r) => setTimeout(r, 50));
expect(part.getState().v).toEqual(2);
});
tap.test('force mode should dispose old StatePart (Subject completes)', async () => {
type TParts = 'forceDispose';
const state = new smartstate.Smartstate<TParts>();
const old = await state.getStatePart<{ v: number }>('forceDispose', { v: 1 });
let oldCompleted = false;
old.select().subscribe({
complete: () => { oldCompleted = true; },
});
await state.getStatePart<{ v: number }>('forceDispose', { v: 2 }, 'force');
expect(oldCompleted).toBeTrue();
});
tap.test('getStatePart should accept 0 as initial value', async () => {
type TParts = 'zeroInit';
const state = new smartstate.Smartstate<TParts>();
// 0 is falsy but should be accepted as a valid initial value
const part = await state.getStatePart<number>('zeroInit', 0);
expect(part.getState()).toEqual(0);
});
export default tap.start();

View File

@@ -40,8 +40,10 @@ tap.test('should dispatch a state action', async (tools) => {
const done = tools.defer(); const done = tools.defer();
const addFavourite = testStatePart.createAction<string>(async (statePart, payload) => { const addFavourite = testStatePart.createAction<string>(async (statePart, payload) => {
const currentState = statePart.getState(); const currentState = statePart.getState();
currentState.currentFavorites.push(payload); return {
return currentState; ...currentState,
currentFavorites: [...currentState.currentFavorites, payload],
};
}); });
testStatePart testStatePart
.waitUntilPresent((state) => { .waitUntilPresent((state) => {

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartstate', name: '@push.rocks/smartstate',
version: '2.0.31', version: '2.1.1',
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.'
} }

View File

@@ -1,3 +1,5 @@
export * from './smartstate.classes.smartstate.js'; export * from './smartstate.classes.smartstate.js';
export * from './smartstate.classes.statepart.js'; export * from './smartstate.classes.statepart.js';
export * from './smartstate.classes.stateaction.js'; export * from './smartstate.classes.stateaction.js';
export * from './smartstate.classes.computed.js';
export * from './smartstate.contextprovider.js';

View File

@@ -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<TResult>(
sources: StatePart<any, any>[],
computeFn: (...states: any[]) => TResult,
): plugins.smartrx.rxjs.Observable<TResult> {
return combineLatest(sources.map((sp) => sp.select())).pipe(
map((states) => computeFn(...states)),
) as plugins.smartrx.rxjs.Observable<TResult>;
}

View File

@@ -1,5 +1,6 @@
import * as plugins from './smartstate.plugins.js'; import * as plugins from './smartstate.plugins.js';
import { StatePart } from './smartstate.classes.statepart.js'; import { StatePart } from './smartstate.classes.statepart.js';
import { computed } from './smartstate.classes.computed.js';
export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent'; export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent';
@@ -11,17 +12,65 @@ export class Smartstate<StatePartNameType extends string> {
private pendingStatePartCreation: Map<string, Promise<StatePart<StatePartNameType, any>>> = new Map(); private pendingStatePartCreation: Map<string, Promise<StatePart<StatePartNameType, any>>> = new Map();
// Batch support
private batchDepth = 0;
private isFlushing = false;
private pendingNotifications = new Set<StatePart<any, any>>();
constructor() {} 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<any, any>): void {
this.pendingNotifications.add(statePart);
}
/**
* batches multiple state updates so subscribers are only notified once all updates complete
*/
public async batch(updateFn: () => Promise<void> | void): Promise<void> {
this.batchDepth++;
try {
await updateFn();
} finally {
this.batchDepth--;
if (this.batchDepth === 0 && !this.isFlushing) {
this.isFlushing = true;
try {
while (this.pendingNotifications.size > 0) {
const pending = [...this.pendingNotifications];
this.pendingNotifications.clear();
for (const sp of pending) {
await sp.notifyChange();
}
}
} finally {
this.isFlushing = false;
}
}
}
}
/**
* creates a computed observable derived from multiple state parts
*/
public computed<TResult>(
sources: StatePart<StatePartNameType, any>[],
computeFn: (...states: any[]) => TResult,
): plugins.smartrx.rxjs.Observable<TResult> {
return computed(sources, computeFn);
}
/** /**
* Allows getting and initializing a new statepart * 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<PayloadType>( public async getStatePart<PayloadType>(
statePartNameArg: StatePartNameType, statePartNameArg: StatePartNameType,
@@ -43,17 +92,15 @@ export class Smartstate<StatePartNameType extends string> {
`State part '${statePartNameArg}' already exists, but initMode is 'mandatory'` `State part '${statePartNameArg}' already exists, but initMode is 'mandatory'`
); );
case 'force': case 'force':
// Force mode: create new state part existingStatePart.dispose();
break; // Fall through to creation break;
case 'soft': case 'soft':
case 'persistent': case 'persistent':
default: default:
// Return existing state part
return existingStatePart as StatePart<StatePartNameType, PayloadType>; return existingStatePart as StatePart<StatePartNameType, PayloadType>;
} }
} else { } else {
// State part doesn't exist if (initialArg === undefined) {
if (!initialArg) {
throw new Error( throw new Error(
`State part '${statePartNameArg}' does not exist and no initial state provided` `State part '${statePartNameArg}' does not exist and no initial state provided`
); );
@@ -73,9 +120,6 @@ export class Smartstate<StatePartNameType extends string> {
/** /**
* Creates a statepart * Creates a statepart
* @param statePartName
* @param initialPayloadArg
* @param initMode
*/ */
private async createStatePart<PayloadType>( private async createStatePart<PayloadType>(
statePartName: StatePartNameType, statePartName: StatePartNameType,
@@ -91,21 +135,20 @@ export class Smartstate<StatePartNameType extends string> {
} }
: null : null
); );
newState.smartstateRef = this;
await newState.init(); await newState.init();
const currentState = newState.getState(); const currentState = newState.getState();
if (initMode === 'persistent' && currentState !== undefined) { if (initMode === 'persistent' && currentState !== undefined) {
// Persisted state exists - merge with defaults, persisted values take precedence
await newState.setState({ await newState.setState({
...initialPayloadArg, ...initialPayloadArg,
...currentState, ...currentState,
}); });
} else { } else {
// No persisted state or non-persistent mode
await newState.setState(initialPayloadArg); await newState.setState(initialPayloadArg);
} }
this.statePartMap[statePartName] = newState; this.statePartMap[statePartName] = newState;
return newState; return newState;
} }
} }

View File

@@ -1,4 +1,3 @@
import * as plugins from './smartstate.plugins.js';
import { StatePart } from './smartstate.classes.statepart.js'; import { StatePart } from './smartstate.classes.statepart.js';
export interface IActionDef<TStateType, TActionPayloadType> { export interface IActionDef<TStateType, TActionPayloadType> {

View File

@@ -1,21 +1,54 @@
import * as plugins from './smartstate.plugins.js'; import * as plugins from './smartstate.plugins.js';
import { Observable, shareReplay, takeUntil } from 'rxjs';
import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js'; import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js';
import type { Smartstate } from './smartstate.classes.smartstate.js';
export type TMiddleware<TPayload> = (
newState: TPayload,
oldState: TPayload | undefined,
) => TPayload | Promise<TPayload>;
/**
* creates an Observable that emits once when the given AbortSignal fires
*/
function fromAbortSignal(signal: AbortSignal): Observable<void> {
return new Observable<void>((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<TStatePartName, TStatePayload> { export class StatePart<TStatePartName, TStatePayload> {
public name: TStatePartName; public name: TStatePartName;
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>(); public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
public stateStore: TStatePayload | undefined; public stateStore: TStatePayload | undefined;
public smartstateRef?: Smartstate<any>;
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer(); private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
private mutationQueue: Promise<any> = Promise.resolve();
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null; 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;
private middlewares: TMiddleware<TStatePayload>[] = [];
// Selector memoization
private selectorCache = new WeakMap<Function, plugins.smartrx.rxjs.Observable<any>>();
private defaultSelectObservable: plugins.smartrx.rxjs.Observable<TStatePayload> | null = null;
constructor(nameArg: TStatePartName, webStoreOptionsArg?: plugins.webstore.IWebStoreOptions) { constructor(nameArg: TStatePartName, webStoreOptionsArg?: plugins.webstore.IWebStoreOptions) {
this.name = nameArg; this.name = nameArg;
// Initialize WebStore if webStoreOptions are provided
if (webStoreOptionsArg) { if (webStoreOptionsArg) {
this.webStoreOptions = webStoreOptionsArg; this.webStoreOptions = webStoreOptionsArg;
} }
@@ -44,22 +77,52 @@ export class StatePart<TStatePartName, TStatePayload> {
} }
/** /**
* sets the stateStore to the new state * adds a middleware that intercepts setState calls.
* @param newStateArg * middleware can transform the state or throw to reject it.
* returns a removal function.
*/ */
public async setState(newStateArg: TStatePayload) { public addMiddleware(middleware: TMiddleware<TStatePayload>): () => 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 (serialized via mutation queue)
*/
public async setState(newStateArg: TStatePayload): Promise<TStatePayload> {
return this.mutationQueue = this.mutationQueue.then(
() => this.applyState(newStateArg),
() => this.applyState(newStateArg),
);
}
/**
* applies the state change (middleware → validate → persist → notify)
*/
private async applyState(newStateArg: TStatePayload): Promise<TStatePayload> {
// Run middleware chain
let processedState = newStateArg;
for (const mw of this.middlewares) {
processedState = await mw(processedState, this.stateStore);
}
// Validate state structure // Validate state structure
if (!this.validateState(newStateArg)) { if (!this.validateState(processedState)) {
throw new Error(`Invalid state structure for state part '${this.name}'`); 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) { 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 // Update in-memory state after successful persistence
this.stateStore = newStateArg; this.stateStore = processedState;
await this.notifyChange(); await this.notifyChange();
return this.stateStore; return this.stateStore;
@@ -67,11 +130,8 @@ export class StatePart<TStatePartName, TStatePayload> {
/** /**
* Validates state structure - can be overridden for custom validation * Validates state structure - can be overridden for custom validation
* @param stateArg
*/ */
protected validateState(stateArg: any): stateArg is TStatePayload { 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; return stateArg !== null && stateArg !== undefined;
} }
@@ -79,64 +139,105 @@ export class StatePart<TStatePartName, TStatePayload> {
* notifies of a change on the state * notifies of a change on the state
*/ */
public async notifyChange() { public async notifyChange() {
if (!this.stateStore) { const snapshot = this.stateStore;
if (snapshot === undefined) {
return; return;
} }
// If inside a batch, defer the notification
if (this.smartstateRef?.isBatching) {
this.smartstateRef.registerPendingNotification(this);
return;
}
const createStateHash = async (stateArg: any) => { const createStateHash = async (stateArg: any) => {
return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg)); return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
}; };
const currentHash = await createStateHash(this.stateStore); try {
if ( const currentHash = await createStateHash(snapshot);
this.lastStateNotificationPayloadHash && if (
currentHash === this.lastStateNotificationPayloadHash this.lastStateNotificationPayloadHash &&
) { currentHash === this.lastStateNotificationPayloadHash
return; ) {
} else { return;
}
this.lastStateNotificationPayloadHash = currentHash; this.lastStateNotificationPayloadHash = currentHash;
} catch (err) {
console.error(`State hash computation failed for '${this.name}':`, err);
} }
this.state.next(this.stateStore); this.state.next(snapshot);
} }
private lastStateNotificationPayloadHash: any; 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() { public notifyChangeCumulative() {
// Debounce: clear any pending notification
if (this.pendingCumulativeNotification) { if (this.pendingCumulativeNotification) {
clearTimeout(this.pendingCumulativeNotification); clearTimeout(this.pendingCumulativeNotification);
} }
this.pendingCumulativeNotification = setTimeout(async () => { this.pendingCumulativeNotification = setTimeout(() => {
this.pendingCumulativeNotification = null; this.pendingCumulativeNotification = null;
if (this.stateStore) { if (this.stateStore !== undefined) {
await this.notifyChange(); this.notifyChange().catch((err) => {
console.error(`notifyChangeCumulative failed for '${this.name}':`, err);
});
} }
}, 0); }, 0);
} }
/** /**
* 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<T = TStatePayload>( public select<T = TStatePayload>(
selectorFn?: (state: TStatePayload) => T selectorFn?: (state: TStatePayload) => T,
options?: { signal?: AbortSignal }
): plugins.smartrx.rxjs.Observable<T> { ): plugins.smartrx.rxjs.Observable<T> {
if (!selectorFn) { const hasSignal = options?.signal != null;
selectorFn = (state: TStatePayload) => <T>(<any>state);
// 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<T>;
}
} else if (this.selectorCache.has(selectorFn)) {
return this.selectorCache.get(selectorFn)!;
}
} }
const mapped = this.state.pipe(
const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
let mapped = this.state.pipe(
plugins.smartrx.rxjs.ops.startWith(this.getState()), plugins.smartrx.rxjs.ops.startWith(this.getState()),
plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined), plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined),
plugins.smartrx.rxjs.ops.map((stateArg) => { plugins.smartrx.rxjs.ops.map((stateArg) => {
try { try {
return selectorFn(stateArg); return effectiveSelectorFn(stateArg);
} catch (e) { } catch (e) {
console.error(`Selector error in state part '${this.name}':`, e); console.error(`Selector error in state part '${this.name}':`, e);
return undefined; 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<TStatePayload>;
} else {
this.selectorCache.set(selectorFn, shared);
}
return shared;
} }
/** /**
@@ -153,26 +254,47 @@ export class StatePart<TStatePartName, TStatePayload> {
*/ */
public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> { public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
await this.cumulativeDeferred.promise; await this.cumulativeDeferred.promise;
const newState = await stateAction.actionDef(this, actionPayload); return this.mutationQueue = this.mutationQueue.then(
await this.setState(newState); async () => {
return this.getState(); const newState = await stateAction.actionDef(this, actionPayload);
return this.applyState(newState);
},
async () => {
const newState = await stateAction.actionDef(this, actionPayload);
return this.applyState(newState);
},
);
} }
/** /**
* waits until a certain part of the state becomes available * waits until a certain part of the state becomes available.
* @param selectorFn * supports optional timeout and AbortSignal.
* @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 optionsOrTimeout?: number | { timeoutMs?: number; signal?: AbortSignal }
): Promise<T> { ): Promise<T> {
// 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<T>(); const done = plugins.smartpromise.defer<T>();
const selectedObservable = this.select(selectorFn); const selectedObservable = this.select(selectorFn);
let resolved = false; let resolved = false;
// Check if already aborted
if (signal?.aborted) {
throw new Error('Aborted');
}
const subscription = selectedObservable.subscribe((value) => { const subscription = selectedObservable.subscribe((value) => {
if (value && !resolved) { if (value !== undefined && value !== null && !resolved) {
resolved = true; resolved = true;
done.resolve(value); done.resolve(value);
} }
@@ -189,12 +311,29 @@ export class StatePart<TStatePartName, TStatePayload> {
}, timeoutMs); }, 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 { try {
const result = await done.promise; const result = await done.promise;
return result; return result;
} finally { } finally {
subscription.unsubscribe(); subscription.unsubscribe();
if (timeoutId) clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
} }
} }
@@ -208,4 +347,20 @@ export class StatePart<TStatePartName, TStatePayload> {
this.cumulativeDeferred.addPromise(resultPromise); this.cumulativeDeferred.addPromise(resultPromise);
await this.setState(await resultPromise); await this.setState(await resultPromise);
} }
/**
* disposes the state part, completing the Subject and cleaning up resources
*/
public dispose(): void {
this.state.complete();
if (this.pendingCumulativeNotification) {
clearTimeout(this.pendingCumulativeNotification);
this.pendingCumulativeNotification = null;
}
this.middlewares.length = 0;
this.selectorCache = new WeakMap();
this.defaultSelectObservable = null;
this.webStore = null;
this.smartstateRef = undefined;
}
} }

View File

@@ -0,0 +1,61 @@
import type { StatePart } from './smartstate.classes.statepart.js';
export interface IContextProviderOptions<TPayload> {
/** the context key (compared by strict equality) */
context: unknown;
/** the state part to provide */
statePart: StatePart<any, TPayload>;
/** 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<TPayload>(
element: HTMLElement,
options: IContextProviderOptions<TPayload>,
): () => 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();
};
}