feat(smartstate): Add middleware, computed, batching, selector memoization, AbortSignal support, and Web Component Context Protocol provider

This commit is contained in:
2026-02-27 11:40:07 +00:00
parent 39aa63bdb3
commit 575477df09
13 changed files with 1091 additions and 344 deletions

465
readme.md
View File

@@ -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<AppParts>();
// 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<YourStatePartNamesEnum>();
const part = await state.getStatePart<IMyState>(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<IUserState>(
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<ILoginPayload>(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<ISettingsState>(
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<T> extends StatePart<string, T> {
class ValidatedPart<T> extends StatePart<string, T> {
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<T>`
`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<TName, TPayload>`
// 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<TState, TPayload>`
### 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<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 |
| Function | Description |
|----------|-------------|
| `computed(sources, fn)` | Create computed observable from state parts |
| `attachContextProvider(element, options)` | Bridge state to Context Protocol |
## License and Legal Information