Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a66518bde8 | |||
| 034ae56536 | |||
| b417e3d049 | |||
| 2b871402cc | |||
| 24c48d8e9b | |||
| 9ba75f6f98 | |||
| d45e1188b1 | |||
| 9312b8908c | |||
| 2f0b39ae41 | |||
| 575477df09 | |||
| 39aa63bdb3 | |||
| c1aa4eae5e |
@@ -16,7 +16,7 @@
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartstate",
|
||||
"description": "A package for handling and managing state in applications.",
|
||||
"description": "A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.",
|
||||
"npmPackagename": "@push.rocks/smartstate",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
@@ -29,7 +29,13 @@
|
||||
"state selection",
|
||||
"state notification",
|
||||
"asynchronous state",
|
||||
"cumulative notification"
|
||||
"cumulative notification",
|
||||
"middleware",
|
||||
"computed state",
|
||||
"batch updates",
|
||||
"context protocol",
|
||||
"web components",
|
||||
"AbortSignal"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
57
changelog.md
57
changelog.md
@@ -1,5 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-27 - 2.3.0 - feat(stateprocess)
|
||||
add managed state processes with lifecycle controls, scheduled actions, and disposal safety
|
||||
|
||||
- introduces StateProcess with start, pause, resume, dispose, status, and auto-pause support
|
||||
- adds createProcess() and createScheduledAction() on StatePart for polling, streams, and recurring actions
|
||||
- adds disposal guards and Smartstate.dispose() to clean up state parts and attached processes
|
||||
- improves selector and computed observables with distinct-until-changed behavior and skipped selector error emissions
|
||||
- renames npmextra.json to .smartconfig.json and updates package tooling dependencies
|
||||
|
||||
## 2026-03-04 - 2.2.1 - fix(smartstate)
|
||||
no changes detected; no version bump required
|
||||
|
||||
- Git diff shows no changes
|
||||
- package.json version is 2.2.0
|
||||
- No files modified — no release needed
|
||||
|
||||
## 2026-03-02 - 2.2.0 - feat(actions)
|
||||
add action context for safe nested dispatch with depth limit to prevent deadlocks
|
||||
|
||||
- Introduce IActionContext to allow actions to dispatch sub-actions inline via context.dispatch
|
||||
- Update IActionDef signature to accept an optional context parameter for backward compatibility
|
||||
- Add StatePart.createActionContext and MAX_NESTED_DISPATCH_DEPTH to track and limit nested dispatch depth (throws on circular dispatchs)
|
||||
- Pass a created context into dispatchAction so actionDefs can safely perform nested dispatches without deadlocking the mutation queue
|
||||
- Add tests covering re-entrancy, deeply nested dispatch, circular dispatch depth detection, backward compatibility with actions that omit context, and concurrent dispatch serialization
|
||||
|
||||
## 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)
|
||||
bump devDependencies and fix README license path
|
||||
|
||||
- Bump @git.zone/tsbundle from ^2.8.3 to ^2.9.0
|
||||
- Bump @types/node from ^25.2.0 to ^25.3.2
|
||||
- Update documented dependency set/version to v2.0.30 in readme.hints.md
|
||||
- Fix README license file path from LICENSE to license in readme.md
|
||||
|
||||
## 2026-02-02 - 2.0.30 - fix(config)
|
||||
update npmextra configuration and improve README: rename package keys, add release registry config, clarify waitUntilPresent timeout and notification/persistence behavior
|
||||
|
||||
|
||||
27
package.json
27
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@push.rocks/smartstate",
|
||||
"version": "2.0.30",
|
||||
"version": "2.3.0",
|
||||
"private": false,
|
||||
"description": "A package for handling and managing state in applications.",
|
||||
"description": "A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
@@ -14,20 +14,19 @@
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.1",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^25.2.0"
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smarthash": "^3.2.6",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/webstore": "^2.0.20"
|
||||
"@push.rocks/webstore": "^2.0.21"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@@ -38,7 +37,7 @@
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
@@ -54,7 +53,13 @@
|
||||
"state selection",
|
||||
"state notification",
|
||||
"asynchronous state",
|
||||
"cumulative notification"
|
||||
"cumulative notification",
|
||||
"middleware",
|
||||
"computed state",
|
||||
"batch updates",
|
||||
"context protocol",
|
||||
"web components",
|
||||
"AbortSignal"
|
||||
],
|
||||
"homepage": "https://code.foss.global/push.rocks/smartstate",
|
||||
"repository": {
|
||||
|
||||
10108
pnpm-lock.yaml
generated
Normal file
10108
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# Smartstate Implementation Notes
|
||||
|
||||
## Current API (as of v2.0.28+)
|
||||
## Current API (as of v2.0.31)
|
||||
|
||||
### State Part Initialization
|
||||
- State parts can be created with different init modes: 'soft' (default), 'mandatory', 'force', 'persistent'
|
||||
@@ -8,53 +8,70 @@
|
||||
- 'mandatory' - requires state part to not exist, fails if it does
|
||||
- 'force' - always creates new state part, overwriting any existing
|
||||
- 'persistent' - like 'soft' but with WebStore persistence (IndexedDB)
|
||||
- Persistent mode automatically calls init() internally - no need to call it manually
|
||||
- Persistent mode automatically calls init() internally
|
||||
- State merge order fixed: initial state takes precedence over stored state
|
||||
|
||||
### Actions
|
||||
- Actions are created with `createAction()` method
|
||||
- Two ways to dispatch actions:
|
||||
1. `stateAction.trigger(payload)` - returns Promise<TStatePayload>
|
||||
2. `await statePart.dispatchAction(stateAction, payload)` - returns Promise<TStatePayload>
|
||||
- Both methods return the same Promise, providing flexibility in usage
|
||||
- Two ways to dispatch: `stateAction.trigger(payload)` or `statePart.dispatchAction(stateAction, payload)`
|
||||
- Both return Promise<TStatePayload>
|
||||
|
||||
### State Management Methods
|
||||
- `select()` - returns Observable with startWith current state, filters undefined states
|
||||
- `waitUntilPresent()` - waits for specific state condition
|
||||
- `select(fn?, { signal? })` - returns Observable, memoized by selector fn ref, supports AbortSignal
|
||||
- `waitUntilPresent(fn?, number | { timeoutMs?, signal? })` - waits for state condition, backward compat with number arg
|
||||
- `stateSetup()` - async state initialization with cumulative defer
|
||||
- `notifyChangeCumulative()` - defers notification to end of call stack
|
||||
- `getState()` - returns current state or undefined
|
||||
- `setState()` - validates state before setting, notifies only on actual changes
|
||||
- `setState()` - runs middleware, validates, persists, notifies
|
||||
- `addMiddleware(fn)` - intercepts setState, returns removal function
|
||||
|
||||
### Middleware
|
||||
- Type: `(newState, oldState) => newState | Promise<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
|
||||
- Uses SHA256 hash to detect actual state changes
|
||||
- Fixed: Hash comparison now properly awaits async hash calculation
|
||||
- Hash comparison properly awaits async hash calculation
|
||||
- Prevents duplicate notifications for identical state values
|
||||
- `notifyChange()` is now async to support proper hash comparison
|
||||
|
||||
### State Validation
|
||||
- Basic validation ensures state is not null/undefined
|
||||
- `validateState()` method can be overridden in subclasses for custom validation
|
||||
- Validation runs on both setState() and when loading from persistent storage
|
||||
- `validateState()` can be overridden in subclasses
|
||||
|
||||
### Type System
|
||||
- Can use either enums or string literal types for state part names
|
||||
- Test uses simple string types: `type TMyStateParts = 'testStatePart'`
|
||||
- State can be undefined initially, handled properly in select() and other methods
|
||||
### Key Notes
|
||||
- `smartstateRef` creates circular ref between StatePart and Smartstate
|
||||
- Use `===` not deep equality for StatePart comparison in tests
|
||||
- Direct rxjs imports used for: Observable, shareReplay, takeUntil, combineLatest, map
|
||||
|
||||
## Recent Fixes (v2.0.24+)
|
||||
1. Fixed state hash bug - now properly compares hash values instead of promises
|
||||
2. Fixed state initialization merge order - initial state now takes precedence
|
||||
3. Ensured stateStore is properly typed as potentially undefined
|
||||
4. Simplified init mode logic with clear behavior for each mode
|
||||
5. Added state validation with extensible validateState() method
|
||||
6. Made notifyChange() async to support proper hash comparison
|
||||
7. Updated select() to filter undefined states
|
||||
|
||||
## Dependency Versions (v2.0.28)
|
||||
## Dependency Versions (v2.0.31)
|
||||
- @git.zone/tsbuild: ^4.1.2
|
||||
- @git.zone/tsbundle: ^2.8.3
|
||||
- @git.zone/tsbundle: ^2.9.0
|
||||
- @git.zone/tsrun: ^2.0.1
|
||||
- @git.zone/tstest: ^3.1.8
|
||||
- @push.rocks/smartjson: ^6.0.0
|
||||
- @types/node: ^25.2.0
|
||||
- @types/node: ^25.3.2
|
||||
|
||||
719
readme.md
719
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartstate
|
||||
|
||||
A powerful TypeScript library for elegant state management using RxJS and reactive programming patterns 🚀
|
||||
A TypeScript-first reactive state management library with processes, middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -8,306 +8,571 @@ 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';
|
||||
|
||||
### 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
|
||||
const myAppSmartState = new Smartstate<YourStatePartNamesEnum>();
|
||||
```
|
||||
|
||||
### 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}`);
|
||||
// 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 });
|
||||
```
|
||||
|
||||
Select a specific part of your state with a selector function:
|
||||
### 🧩 State Parts & Init Modes
|
||||
|
||||
State parts are isolated, typed units of state — the building blocks of your application's state tree. Create them via `getStatePart()`:
|
||||
|
||||
```typescript
|
||||
userStatePart.select(state => state.username).subscribe((username) => {
|
||||
if (username) {
|
||||
console.log(`Current user: ${username}`);
|
||||
}
|
||||
});
|
||||
const part = await state.getStatePart<IMyState>(name, initialState, initMode);
|
||||
```
|
||||
|
||||
### 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, disposing and 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 string literal union types or enums 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
|
||||
```
|
||||
|
||||
### 🔭 Selecting State
|
||||
|
||||
`select()` returns an RxJS Observable that emits the current value immediately (via `BehaviorSubject`) 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.
|
||||
|
||||
**Change detection** is built in: `select()` uses `distinctUntilChanged` with deep JSON comparison, so subscribers only fire when the selected value actually changes. Selecting `s => s.name` won't re-emit when only `s.count` changes.
|
||||
|
||||
#### ✂️ 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
|
||||
interface ILoginPayload {
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const loginUserAction = userStatePart.createAction<ILoginPayload>(async (statePart, payload) => {
|
||||
return { ...statePart.getState(), isLoggedIn: true, username: payload.username };
|
||||
const loginAction = userState.createAction<ILoginPayload>(async (statePart, payload) => {
|
||||
const current = statePart.getState();
|
||||
return { ...current, name: payload.username, loggedIn: true };
|
||||
});
|
||||
|
||||
// Dispatch the action to update the state
|
||||
const newState = await loginUserAction.trigger({ username: 'johnDoe' });
|
||||
// Two equivalent ways to dispatch:
|
||||
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. All dispatches are serialized through a mutation queue, so concurrent dispatches never cause lost updates.
|
||||
|
||||
There are two ways to dispatch actions:
|
||||
#### 🔗 Nested Actions (Action Context)
|
||||
|
||||
When you need to dispatch sub-actions from within an action, use the `context` parameter. This is critical because calling `dispatchAction()` directly from inside an action would deadlock (it tries to acquire the mutation queue that's already held). The context's `dispatch()` bypasses the queue and executes inline:
|
||||
|
||||
```typescript
|
||||
// Method 1: Using trigger on the action (returns promise)
|
||||
const newState = await loginUserAction.trigger({ username: 'johnDoe' });
|
||||
const incrementAction = userState.createAction<number>(async (statePart, amount) => {
|
||||
const current = statePart.getState();
|
||||
return { ...current, count: current.count + amount };
|
||||
});
|
||||
|
||||
// Method 2: Using dispatchAction on the state part (returns promise)
|
||||
const newState = await userStatePart.dispatchAction(loginUserAction, { username: 'johnDoe' });
|
||||
const doubleIncrementAction = userState.createAction<number>(async (statePart, amount, context) => {
|
||||
// ✅ Safe: uses context.dispatch() which bypasses the mutation queue
|
||||
await context.dispatch(incrementAction, amount);
|
||||
const current = statePart.getState();
|
||||
return { ...current, count: current.count + amount };
|
||||
});
|
||||
|
||||
// ❌ DON'T do this inside an action — it will deadlock:
|
||||
// await statePart.dispatchAction(someAction, payload);
|
||||
```
|
||||
|
||||
Both methods return a Promise with the new state payload.
|
||||
A built-in depth limit (10 levels) prevents infinite circular dispatch chains, throwing a clear error if exceeded.
|
||||
|
||||
### Additional State Methods
|
||||
### 🔄 Processes (Polling, Streams & Scheduled Tasks)
|
||||
|
||||
`StatePart` provides several useful methods for state management:
|
||||
Processes are managed, pausable observable-to-state bridges — the "side effects" layer. They tie an ongoing data source (polling, WebSockets, event streams) to state updates with full lifecycle control and optional auto-pause.
|
||||
|
||||
#### Basic Process: Polling an API
|
||||
|
||||
```typescript
|
||||
// Get current state (may be undefined initially)
|
||||
const currentState = userStatePart.getState();
|
||||
if (currentState) {
|
||||
console.log('Current user:', currentState.username);
|
||||
}
|
||||
import { interval, switchMap, from } from 'rxjs';
|
||||
|
||||
// Wait for state to be present
|
||||
await userStatePart.waitUntilPresent();
|
||||
const metricsPoller = dashboard.createProcess<{ cpu: number; memory: number }>({
|
||||
// Producer: an Observable factory — called on start and each resume
|
||||
producer: () => interval(5000).pipe(
|
||||
switchMap(() => from(fetch('/api/metrics').then(r => r.json()))),
|
||||
),
|
||||
// Reducer: folds each produced value into state (runs through middleware & validation)
|
||||
reducer: (currentState, metrics) => ({
|
||||
...currentState,
|
||||
metrics,
|
||||
lastUpdated: Date.now(),
|
||||
}),
|
||||
autoPause: 'visibility', // ⏸️ Stop polling when the tab is hidden
|
||||
autoStart: true, // ▶️ Start immediately
|
||||
});
|
||||
|
||||
// Wait for a specific property to be present
|
||||
await userStatePart.waitUntilPresent(state => state.username);
|
||||
// Full lifecycle control
|
||||
metricsPoller.pause(); // Unsubscribes from producer
|
||||
metricsPoller.resume(); // Re-subscribes (fresh subscription)
|
||||
metricsPoller.dispose(); // Permanent cleanup
|
||||
|
||||
// Wait with a timeout (throws error if condition not met within timeout)
|
||||
// Observe status reactively
|
||||
metricsPoller.status; // 'idle' | 'running' | 'paused' | 'disposed'
|
||||
metricsPoller.status$.subscribe(s => console.log('Process:', s));
|
||||
```
|
||||
|
||||
#### Scheduled Actions
|
||||
|
||||
Dispatch an existing action on a recurring interval — syntactic sugar over `createProcess`:
|
||||
|
||||
```typescript
|
||||
const refreshAction = dashboard.createAction<void>(async (sp) => {
|
||||
const data = await fetch('/api/dashboard').then(r => r.json());
|
||||
return { ...sp.getState()!, ...data, lastUpdated: Date.now() };
|
||||
});
|
||||
|
||||
// Dispatches refreshAction every 30 seconds, auto-pauses when tab is hidden
|
||||
const scheduled = dashboard.createScheduledAction({
|
||||
action: refreshAction,
|
||||
payload: undefined,
|
||||
intervalMs: 30000,
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
|
||||
// It's a full StateProcess — pause, resume, dispose all work
|
||||
scheduled.dispose();
|
||||
```
|
||||
|
||||
#### Custom Auto-Pause Signals
|
||||
|
||||
Pass any `Observable<boolean>` as the auto-pause signal — `true` means active, `false` means pause:
|
||||
|
||||
```typescript
|
||||
import { fromEvent, map, startWith } from 'rxjs';
|
||||
|
||||
// Pause when offline, resume when online
|
||||
const onlineSignal = fromEvent(window, 'online').pipe(
|
||||
startWith(null),
|
||||
map(() => navigator.onLine),
|
||||
);
|
||||
|
||||
const syncProcess = userPart.createProcess<SyncPayload>({
|
||||
producer: () => interval(10000).pipe(
|
||||
switchMap(() => from(syncWithServer())),
|
||||
),
|
||||
reducer: (state, result) => ({ ...state, ...result }),
|
||||
autoPause: onlineSignal,
|
||||
});
|
||||
syncProcess.start();
|
||||
```
|
||||
|
||||
#### WebSocket / Live Streams
|
||||
|
||||
Pause disconnects; resume creates a fresh connection:
|
||||
|
||||
```typescript
|
||||
const liveProcess = tickerPart.createProcess<TradeEvent>({
|
||||
producer: () => new Observable<TradeEvent>(subscriber => {
|
||||
const ws = new WebSocket('wss://trades.example.com');
|
||||
ws.onmessage = (e) => subscriber.next(JSON.parse(e.data));
|
||||
ws.onerror = (e) => subscriber.error(e);
|
||||
ws.onclose = () => subscriber.complete();
|
||||
return () => ws.close(); // Teardown: close WebSocket on unsubscribe
|
||||
}),
|
||||
reducer: (state, trade) => ({
|
||||
...state,
|
||||
lastPrice: trade.price,
|
||||
trades: [...state.trades.slice(-99), trade],
|
||||
}),
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
liveProcess.start();
|
||||
```
|
||||
|
||||
#### Error Recovery
|
||||
|
||||
If a producer errors, the process gracefully transitions to `'paused'` instead of dying. Call `resume()` to retry with a fresh subscription:
|
||||
|
||||
```typescript
|
||||
process.start();
|
||||
// Producer errors → status becomes 'paused'
|
||||
process.resume(); // Creates a fresh subscription — retry
|
||||
```
|
||||
|
||||
#### Process Cleanup Cascades
|
||||
|
||||
Disposing a `StatePart` or `Smartstate` instance automatically disposes all attached processes:
|
||||
|
||||
```typescript
|
||||
const p1 = part.createProcess({ ... });
|
||||
const p2 = part.createProcess({ ... });
|
||||
p1.start();
|
||||
p2.start();
|
||||
|
||||
part.dispose();
|
||||
console.log(p1.status); // 'disposed'
|
||||
console.log(p2.status); // 'disposed'
|
||||
```
|
||||
|
||||
### 🛡️ Middleware
|
||||
|
||||
Intercept every `setState()` call to transform, validate, log, or reject state changes:
|
||||
|
||||
```typescript
|
||||
// Logging middleware
|
||||
userState.addMiddleware((newState, oldState) => {
|
||||
console.log('State changing:', oldState, '→', 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() };
|
||||
});
|
||||
|
||||
// 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
|
||||
```
|
||||
|
||||
Middleware runs **sequentially** in insertion order. If any middleware throws, the state remains unchanged — the operation is **atomic**. Process-driven state updates go through middleware too.
|
||||
|
||||
### 🧮 Computed / Derived State
|
||||
|
||||
Derive reactive values from one or more state parts using `combineLatest` under the hood:
|
||||
|
||||
```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 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. They also use `distinctUntilChanged` to avoid redundant emissions when the derived value hasn't actually changed.
|
||||
|
||||
### 📦 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 {
|
||||
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) {
|
||||
// e.message is 'Aborted' or 'waitUntilPresent timed out after 5000ms'
|
||||
}
|
||||
```
|
||||
|
||||
// Setup initial state with async operations
|
||||
await userStatePart.stateSetup(async (statePart) => {
|
||||
const userData = await fetchUserData();
|
||||
### 🌐 Context Protocol Bridge (Web Components)
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
// Defer notification to end of call stack (debounced)
|
||||
userStatePart.notifyChangeCumulative();
|
||||
// Any dispatchAction() calls will automatically wait for stateSetup() to finish
|
||||
```
|
||||
|
||||
### Persistent State with WebStore
|
||||
### 🧹 Disposal & Cleanup
|
||||
|
||||
`Smartstate` supports persistent states using WebStore (IndexedDB-based storage), allowing you to maintain state across sessions:
|
||||
Both `Smartstate` and individual `StatePart` instances support disposal for proper cleanup:
|
||||
|
||||
```typescript
|
||||
const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>(
|
||||
AppStateParts.SettingsState,
|
||||
{ theme: 'light' }, // Initial/default state
|
||||
'persistent' // Mode
|
||||
);
|
||||
// Dispose a single state part — completes the BehaviorSubject, clears middleware, caches,
|
||||
// and disposes all attached processes
|
||||
userState.dispose();
|
||||
|
||||
// Dispose the entire Smartstate instance — disposes all state parts and clears internal maps
|
||||
state.dispose();
|
||||
```
|
||||
|
||||
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)
|
||||
After disposal, `setState()` and `dispatchAction()` will throw if called on a disposed `StatePart`. Calling `start()`, `pause()`, or `resume()` on a disposed `StateProcess` also throws.
|
||||
|
||||
### State Validation
|
||||
### 🏎️ Performance
|
||||
|
||||
`Smartstate` includes built-in state validation to ensure data integrity:
|
||||
Smartstate is built with performance in mind:
|
||||
|
||||
```typescript
|
||||
// Basic validation (built-in) ensures state is not null or undefined
|
||||
await userStatePart.setState(null); // Throws error: Invalid state structure
|
||||
- **🔒 SHA256 Change Detection** — Uses content hashing to detect actual changes. Identical state values don't trigger notifications, even with different object references.
|
||||
- **🎯 distinctUntilChanged on Selectors** — Sub-selectors only fire when the selected slice actually changes. `select(s => s.name)` won't emit when `s.count` changes.
|
||||
- **♻️ Selector Memoization** — `select(fn)` caches observables by function reference and shares them via `shareReplay({ refCount: true })`. Multiple subscribers share one upstream subscription.
|
||||
- **📦 Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification at the end of the call stack.
|
||||
- **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation. All `setState()` and `dispatchAction()` calls are serialized through a mutation queue. Process values are serialized through their own internal queue.
|
||||
- **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency.
|
||||
- **⏸️ Batch Deferred Notifications** — `batch()` suppresses all subscriber notifications until every update in the batch completes.
|
||||
|
||||
// Custom validation by extending StatePart
|
||||
class ValidatedStatePart<T> extends StatePart<string, T> {
|
||||
protected validateState(stateArg: any): stateArg is T {
|
||||
return super.validateState(stateArg) && /* your validation */;
|
||||
}
|
||||
}
|
||||
```
|
||||
## API Reference
|
||||
|
||||
### Performance Optimization
|
||||
### `Smartstate<T>`
|
||||
|
||||
`Smartstate` includes advanced performance optimizations:
|
||||
| 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 |
|
||||
| `dispose()` | Dispose all state parts and clear internal state |
|
||||
| `isBatching` | `boolean` — whether a batch is currently active |
|
||||
|
||||
- **🔒 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
|
||||
### `StatePart<TName, TPayload>`
|
||||
|
||||
### RxJS Integration
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getState()` | Get current state synchronously (`TPayload \| undefined`) |
|
||||
| `setState(newState)` | Set state — runs middleware → validates → persists → notifies |
|
||||
| `select(selectorFn?, options?)` | 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? }` |
|
||||
| `createProcess(options)` | Create a managed, pausable process tied to this state part |
|
||||
| `createScheduledAction(options)` | Create a process that dispatches an action on a recurring interval |
|
||||
| `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 |
|
||||
| `dispose()` | Complete the BehaviorSubject, dispose processes, clear middleware and caches |
|
||||
|
||||
`Smartstate` leverages RxJS for reactive state management:
|
||||
### `StateAction<TState, TPayload>`
|
||||
|
||||
```typescript
|
||||
// State is exposed as an RxJS Subject
|
||||
const stateObservable = userStatePart.select();
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `trigger(payload)` | Dispatch the action on its associated state part |
|
||||
|
||||
// Automatically starts with current state value
|
||||
stateObservable.subscribe((state) => {
|
||||
console.log('Current state:', state);
|
||||
});
|
||||
### `StateProcess<TName, TPayload, TProducerValue>`
|
||||
|
||||
// Use selectors for specific properties
|
||||
userStatePart.select(state => state.username)
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter(username => username !== undefined)
|
||||
)
|
||||
.subscribe(username => {
|
||||
console.log('Username changed:', username);
|
||||
});
|
||||
```
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `start()` | Start the process (subscribes to producer, sets up auto-pause) |
|
||||
| `pause()` | Pause the process (unsubscribes from producer) |
|
||||
| `resume()` | Resume a paused process (fresh subscription to producer) |
|
||||
| `dispose()` | Permanently stop the process and clean up |
|
||||
| `status` | Current status: `'idle' \| 'running' \| 'paused' \| 'disposed'` |
|
||||
| `status$` | Observable of status transitions |
|
||||
|
||||
### Complete Example
|
||||
### `IActionContext<TState>`
|
||||
|
||||
Here's a comprehensive example showcasing the power of `@push.rocks/smartstate`:
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `dispatch(action, payload)` | Dispatch a sub-action inline (bypasses mutation queue). Available as the third argument to action definitions |
|
||||
|
||||
```typescript
|
||||
import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
|
||||
### Standalone Functions
|
||||
|
||||
// Define your state structure
|
||||
type AppStateParts = 'user' | 'settings' | 'cart';
|
||||
| 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 |
|
||||
|
||||
interface IUserState {
|
||||
isLoggedIn: boolean;
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
### Exported Types
|
||||
|
||||
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 |
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `TInitMode` | `'soft' \| 'mandatory' \| 'force' \| 'persistent'` |
|
||||
| `TMiddleware<TPayload>` | `(newState, oldState) => TPayload \| Promise<TPayload>` |
|
||||
| `IActionDef<TState, TPayload>` | Action definition function signature (receives statePart, payload, context?) |
|
||||
| `IActionContext<TState>` | Context for safe nested dispatch within actions |
|
||||
| `IContextProviderOptions<TPayload>` | Options for `attachContextProvider` |
|
||||
| `IProcessOptions<TPayload, TValue>` | Options for `createProcess` (producer, reducer, autoPause, autoStart) |
|
||||
| `IScheduledActionOptions<TPayload, TActionPayload>` | Options for `createScheduledAction` (action, payload, intervalMs, autoPause) |
|
||||
| `TProcessStatus` | `'idle' \| 'running' \| 'paused' \| 'disposed'` |
|
||||
| `TAutoPause` | `'visibility' \| Observable<boolean> \| false` |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ interface ITestState {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Init mode tests
|
||||
// ============================
|
||||
|
||||
tap.test('should handle soft init mode (default)', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
@@ -22,23 +25,20 @@ tap.test('should handle soft init mode (default)', async () => {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
// Second call should return existing
|
||||
|
||||
const statePart2 = await state.getStatePart<ITestState>('initTest');
|
||||
expect(statePart1).toEqual(statePart2);
|
||||
expect(statePart1 === statePart2).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should handle mandatory init mode', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation should succeed
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'mandatory');
|
||||
expect(statePart1).toBeInstanceOf(smartstate.StatePart);
|
||||
|
||||
// Second call with mandatory should fail
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await state.getStatePart<ITestState>('initTest', {
|
||||
@@ -54,26 +54,24 @@ tap.test('should handle mandatory init mode', async () => {
|
||||
|
||||
tap.test('should handle force init mode', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('forceTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
expect(statePart1.getState()?.value).toEqual(1);
|
||||
|
||||
// Force should create new state part
|
||||
|
||||
const statePart2 = await state.getStatePart<ITestState>('forceTest', {
|
||||
value: 2,
|
||||
nested: { data: 'forced' }
|
||||
}, 'force');
|
||||
expect(statePart2.getState()?.value).toEqual(2);
|
||||
expect(statePart1).not.toEqual(statePart2);
|
||||
expect(statePart1 === statePart2).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should handle missing initial state error', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
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 () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
// Setting null should fail validation
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await statePart.setState(null as any);
|
||||
@@ -106,20 +103,17 @@ tap.test('should handle state validation', async () => {
|
||||
tap.test('should handle undefined state in select', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = new smartstate.StatePart<TTestStateParts, ITestState>('initTest');
|
||||
|
||||
// Select should filter out undefined states
|
||||
|
||||
const values: (ITestState | undefined)[] = [];
|
||||
statePart.select().subscribe(val => values.push(val));
|
||||
|
||||
// Initially undefined, should not emit
|
||||
|
||||
expect(values).toHaveLength(0);
|
||||
|
||||
// After setting state, should emit
|
||||
|
||||
await statePart.setState({
|
||||
value: 1,
|
||||
nested: { data: 'test' }
|
||||
});
|
||||
|
||||
|
||||
expect(values).toHaveLength(1);
|
||||
expect(values[0]).toEqual({
|
||||
value: 1,
|
||||
@@ -133,25 +127,869 @@ tap.test('should not notify on duplicate state', async () => {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
|
||||
let notificationCount = 0;
|
||||
// Use select() to get initial value + changes
|
||||
statePart.select().subscribe(() => notificationCount++);
|
||||
|
||||
// Should have received initial state
|
||||
|
||||
expect(notificationCount).toEqual(1);
|
||||
|
||||
// Set same state multiple times
|
||||
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
|
||||
// Should still be 1 (no new notifications for duplicate state)
|
||||
|
||||
expect(notificationCount).toEqual(1);
|
||||
|
||||
// Change state should notify
|
||||
|
||||
await statePart.setState({ value: 2, nested: { data: 'changed' } });
|
||||
expect(notificationCount).toEqual(2);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
// ============================
|
||||
// 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);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Action context re-entrancy tests
|
||||
// ============================
|
||||
|
||||
tap.test('context.dispatch should not deadlock on same StatePart', async () => {
|
||||
type TParts = 'reentrantAction';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ count: number }>('reentrantAction', { count: 0 });
|
||||
|
||||
const innerIncrement = part.createAction<void>(async (sp) => {
|
||||
return { count: sp.getState().count + 1 };
|
||||
});
|
||||
|
||||
const outerAction = part.createAction<void>(async (sp, _payload, context) => {
|
||||
// This would deadlock without the context.dispatch() mechanism
|
||||
await context.dispatch(innerIncrement, undefined);
|
||||
return sp.getState();
|
||||
});
|
||||
|
||||
const result = await part.dispatchAction(outerAction, undefined);
|
||||
expect(result.count).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('deeply nested context.dispatch should work (3 levels)', async () => {
|
||||
type TParts = 'deepNested';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ steps: string[] }>('deepNested', { steps: [] });
|
||||
|
||||
const appendStep = part.createAction<string>(async (sp, step) => {
|
||||
return { steps: [...sp.getState().steps, step] };
|
||||
});
|
||||
|
||||
const level2 = part.createAction<void>(async (sp, _payload, context) => {
|
||||
await context.dispatch(appendStep, 'level-2');
|
||||
return sp.getState();
|
||||
});
|
||||
|
||||
const level1 = part.createAction<void>(async (sp, _payload, context) => {
|
||||
await context.dispatch(appendStep, 'level-1');
|
||||
await context.dispatch(level2, undefined);
|
||||
await context.dispatch(appendStep, 'level-1-after');
|
||||
return sp.getState();
|
||||
});
|
||||
|
||||
await part.dispatchAction(level1, undefined);
|
||||
expect(part.getState().steps).toEqual(['level-1', 'level-2', 'level-1-after']);
|
||||
});
|
||||
|
||||
tap.test('circular context.dispatch should throw max depth error', async () => {
|
||||
type TParts = 'circular';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ count: number }>('circular', { count: 0 });
|
||||
|
||||
// Create a self-referencing action that will loop forever
|
||||
const circularAction: smartstate.StateAction<{ count: number }, void> = part.createAction<void>(
|
||||
async (sp, _payload, context) => {
|
||||
const current = sp.getState();
|
||||
if (current.count < 100) {
|
||||
// This should eventually hit the depth limit
|
||||
await context.dispatch(circularAction, undefined);
|
||||
}
|
||||
return sp.getState();
|
||||
}
|
||||
);
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await part.dispatchAction(circularAction, undefined);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toMatch(/Maximum nested action dispatch depth/);
|
||||
});
|
||||
|
||||
tap.test('actions without context arg should still work (backward compat)', async () => {
|
||||
type TParts = 'backwardCompat';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ value: number }>('backwardCompat', { value: 0 });
|
||||
|
||||
// Old-style action that doesn't use the context parameter
|
||||
const simpleAction = part.createAction<number>(async (sp, payload) => {
|
||||
return { value: payload };
|
||||
});
|
||||
|
||||
await part.dispatchAction(simpleAction, 42);
|
||||
expect(part.getState().value).toEqual(42);
|
||||
});
|
||||
|
||||
tap.test('concurrent dispatches still serialize correctly with context feature', async () => {
|
||||
type TParts = 'concurrentWithContext';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ count: number }>('concurrentWithContext', { count: 0 });
|
||||
|
||||
const increment = part.createAction<void>(async (sp) => {
|
||||
const current = sp.getState();
|
||||
return { count: current.count + 1 };
|
||||
});
|
||||
|
||||
// Fire 10 concurrent dispatches — must still serialize
|
||||
const promises: Promise<any>[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(part.dispatchAction(increment, undefined));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(part.getState().count).toEqual(10);
|
||||
});
|
||||
|
||||
// ── distinctUntilChanged on selectors ──────────────────────────────────
|
||||
tap.test('select should not emit when selected sub-state has not changed', async () => {
|
||||
type TParts = 'distinctSelector';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ name: string; count: number }>('distinctSelector', { name: 'alice', count: 0 });
|
||||
|
||||
const nameSelector = (s: { name: string; count: number }) => s.name;
|
||||
const nameValues: string[] = [];
|
||||
part.select(nameSelector).subscribe((v) => nameValues.push(v));
|
||||
|
||||
// Wait for initial emission
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(1);
|
||||
expect(nameValues[0]).toEqual('alice');
|
||||
|
||||
// Change only count — name selector should NOT re-emit
|
||||
await part.setState({ name: 'alice', count: 1 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(1);
|
||||
|
||||
// Change name — name selector SHOULD emit
|
||||
await part.setState({ name: 'bob', count: 1 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(2);
|
||||
expect(nameValues[1]).toEqual('bob');
|
||||
});
|
||||
|
||||
// ── Selector error skipping ────────────────────────────────────────────
|
||||
tap.test('selector errors should be skipped, not emit undefined', async () => {
|
||||
type TParts = 'selectorError';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ value: number }>('selectorError', { value: 1 });
|
||||
|
||||
let callCount = 0;
|
||||
const faultySelector = (s: { value: number }) => {
|
||||
if (s.value === 2) throw new Error('selector boom');
|
||||
return s.value;
|
||||
};
|
||||
|
||||
const values: number[] = [];
|
||||
part.select(faultySelector).subscribe((v) => {
|
||||
callCount++;
|
||||
values.push(v);
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1]);
|
||||
|
||||
// This setState triggers a selector error — should be skipped, no undefined emitted
|
||||
await part.setState({ value: 2 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1]); // no new emission
|
||||
expect(callCount).toEqual(1);
|
||||
|
||||
// Normal value again
|
||||
await part.setState({ value: 3 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
// ── Smartstate.dispose() ───────────────────────────────────────────────
|
||||
tap.test('Smartstate.dispose should dispose all state parts', async () => {
|
||||
type TParts = 'partA' | 'partB';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const partA = await state.getStatePart<{ x: number }>('partA', { x: 1 });
|
||||
const partB = await state.getStatePart<{ y: number }>('partB', { y: 2 });
|
||||
|
||||
let aCompleted = false;
|
||||
let bCompleted = false;
|
||||
partA.select().subscribe({ complete: () => { aCompleted = true; } });
|
||||
partB.select().subscribe({ complete: () => { bCompleted = true; } });
|
||||
|
||||
state.dispose();
|
||||
|
||||
expect(aCompleted).toBeTrue();
|
||||
expect(bCompleted).toBeTrue();
|
||||
});
|
||||
|
||||
// ── Post-dispose setState throws ───────────────────────────────────────
|
||||
tap.test('setState on disposed StatePart should throw', async () => {
|
||||
type TParts = 'disposedPart';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('disposedPart', { v: 0 });
|
||||
|
||||
part.dispose();
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await part.setState({ v: 1 });
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
expect((e as Error).message).toInclude('disposed');
|
||||
}
|
||||
expect(threw).toBeTrue();
|
||||
});
|
||||
|
||||
// ── Post-dispose dispatchAction throws ─────────────────────────────────
|
||||
tap.test('dispatchAction on disposed StatePart should throw', async () => {
|
||||
type TParts = 'disposedDispatch';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('disposedDispatch', { v: 0 });
|
||||
|
||||
const action = part.createAction<number>(async (sp, payload) => {
|
||||
return { v: payload };
|
||||
});
|
||||
|
||||
part.dispose();
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await part.dispatchAction(action, 5);
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
expect((e as Error).message).toInclude('disposed');
|
||||
}
|
||||
expect(threw).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
278
test/test.stateprocess.ts
Normal file
278
test/test.stateprocess.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartstate from '../ts/index.js';
|
||||
import { Subject, of, Observable, throwError, concat } from 'rxjs';
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||
tap.test('process should start in idle status', async () => {
|
||||
const state = new smartstate.Smartstate<'test'>();
|
||||
const part = await state.getStatePart<{ v: number }>('test', { v: 0 });
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => of(1),
|
||||
reducer: (s, v) => ({ v: s.v + v }),
|
||||
});
|
||||
expect(process.status).toEqual('idle');
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
tap.test('start/pause/resume/dispose lifecycle', async () => {
|
||||
const state = new smartstate.Smartstate<'lifecycle'>();
|
||||
const part = await state.getStatePart<{ v: number }>('lifecycle', { v: 0 });
|
||||
const subject = new Subject<number>();
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ v: s.v + v }),
|
||||
});
|
||||
|
||||
expect(process.status).toEqual('idle');
|
||||
process.start();
|
||||
expect(process.status).toEqual('running');
|
||||
process.pause();
|
||||
expect(process.status).toEqual('paused');
|
||||
process.resume();
|
||||
expect(process.status).toEqual('running');
|
||||
process.dispose();
|
||||
expect(process.status).toEqual('disposed');
|
||||
});
|
||||
|
||||
// ── Producer → state integration ───────────────────────────────────────
|
||||
tap.test('producer values should update state through reducer', async () => {
|
||||
const state = new smartstate.Smartstate<'producer'>();
|
||||
const part = await state.getStatePart<{ values: number[] }>('producer', { values: [] });
|
||||
const subject = new Subject<number>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ values: [...s.values, v] }),
|
||||
});
|
||||
process.start();
|
||||
|
||||
subject.next(1);
|
||||
subject.next(2);
|
||||
subject.next(3);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(part.getState()!.values).toEqual([1, 2, 3]);
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Pause stops producer, resume restarts ──────────────────────────────
|
||||
tap.test('pause should stop receiving values, resume should restart', async () => {
|
||||
const state = new smartstate.Smartstate<'pauseResume'>();
|
||||
const part = await state.getStatePart<{ count: number }>('pauseResume', { count: 0 });
|
||||
const subject = new Subject<number>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ count: s.count + v }),
|
||||
});
|
||||
process.start();
|
||||
|
||||
subject.next(1);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(1);
|
||||
|
||||
process.pause();
|
||||
subject.next(1); // should be ignored — producer unsubscribed
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(1); // unchanged
|
||||
|
||||
process.resume();
|
||||
subject.next(1);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(2);
|
||||
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Auto-pause with custom Observable ──────────────────────────────────
|
||||
tap.test('auto-pause with custom Observable<boolean> signal', async () => {
|
||||
const state = new smartstate.Smartstate<'autoPause'>();
|
||||
const part = await state.getStatePart<{ count: number }>('autoPause', { count: 0 });
|
||||
const producer = new Subject<number>();
|
||||
const pauseSignal = new Subject<boolean>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => producer.asObservable(),
|
||||
reducer: (s, v) => ({ count: s.count + v }),
|
||||
autoPause: pauseSignal.asObservable(),
|
||||
});
|
||||
process.start();
|
||||
|
||||
producer.next(1);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(1);
|
||||
|
||||
// Signal pause
|
||||
pauseSignal.next(false);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(process.status).toEqual('paused');
|
||||
|
||||
producer.next(1); // ignored
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(1);
|
||||
|
||||
// Signal resume
|
||||
pauseSignal.next(true);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(process.status).toEqual('running');
|
||||
|
||||
producer.next(1);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(2);
|
||||
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Auto-pause 'visibility' in Node.js (no document) ──────────────────
|
||||
tap.test('autoPause visibility should be always-active in Node.js', async () => {
|
||||
const state = new smartstate.Smartstate<'vis'>();
|
||||
const part = await state.getStatePart<{ v: number }>('vis', { v: 0 });
|
||||
const subject = new Subject<number>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ v: v }),
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
process.start();
|
||||
expect(process.status).toEqual('running');
|
||||
|
||||
subject.next(42);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.v).toEqual(42);
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Scheduled action ───────────────────────────────────────────────────
|
||||
tap.test('createScheduledAction should dispatch action on interval', async () => {
|
||||
const state = new smartstate.Smartstate<'scheduled'>();
|
||||
const part = await state.getStatePart<{ ticks: number }>('scheduled', { ticks: 0 });
|
||||
|
||||
const tickAction = part.createAction<void>(async (sp) => {
|
||||
return { ticks: sp.getState()!.ticks + 1 };
|
||||
});
|
||||
|
||||
const scheduled = part.createScheduledAction({
|
||||
action: tickAction,
|
||||
payload: undefined,
|
||||
intervalMs: 50,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 280));
|
||||
scheduled.dispose();
|
||||
|
||||
// Should have ticked at least 3 times in ~280ms with 50ms interval
|
||||
expect(part.getState()!.ticks).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
// ── StatePart.dispose cascades ─────────────────────────────────────────
|
||||
tap.test('StatePart.dispose should dispose all processes', async () => {
|
||||
const state = new smartstate.Smartstate<'cascade'>();
|
||||
const part = await state.getStatePart<{ v: number }>('cascade', { v: 0 });
|
||||
|
||||
const p1 = part.createProcess<number>({
|
||||
producer: () => new Subject<number>().asObservable(),
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
const p2 = part.createProcess<number>({
|
||||
producer: () => new Subject<number>().asObservable(),
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
p1.start();
|
||||
p2.start();
|
||||
|
||||
part.dispose();
|
||||
expect(p1.status).toEqual('disposed');
|
||||
expect(p2.status).toEqual('disposed');
|
||||
});
|
||||
|
||||
// ── status$ observable ─────────────────────────────────────────────────
|
||||
tap.test('status$ should emit lifecycle transitions', async () => {
|
||||
const state = new smartstate.Smartstate<'status$'>();
|
||||
const part = await state.getStatePart<{ v: number }>('status$', { v: 0 });
|
||||
const subject = new Subject<number>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
|
||||
const statuses: string[] = [];
|
||||
process.status$.subscribe((s) => statuses.push(s));
|
||||
|
||||
process.start();
|
||||
process.pause();
|
||||
process.resume();
|
||||
process.dispose();
|
||||
|
||||
expect(statuses).toEqual(['idle', 'running', 'paused', 'running', 'disposed']);
|
||||
});
|
||||
|
||||
// ── Producer error → graceful pause ────────────────────────────────────
|
||||
tap.test('producer error should pause process gracefully', async () => {
|
||||
const state = new smartstate.Smartstate<'error'>();
|
||||
const part = await state.getStatePart<{ v: number }>('error', { v: 0 });
|
||||
|
||||
let callCount = 0;
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First subscription: emit 1, then error
|
||||
return concat(of(1), throwError(() => new Error('boom')));
|
||||
}
|
||||
// After resume: emit 2 successfully
|
||||
return of(2);
|
||||
},
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
process.start();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(process.status).toEqual('paused');
|
||||
expect(part.getState()!.v).toEqual(1); // got the value before error
|
||||
|
||||
// Resume creates a fresh subscription
|
||||
process.resume();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.v).toEqual(2);
|
||||
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Disposed guards ────────────────────────────────────────────────────
|
||||
tap.test('start/pause/resume on disposed process should throw', async () => {
|
||||
const state = new smartstate.Smartstate<'guards'>();
|
||||
const part = await state.getStatePart<{ v: number }>('guards', { v: 0 });
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => of(1),
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
process.dispose();
|
||||
|
||||
let errors = 0;
|
||||
try { process.start(); } catch { errors++; }
|
||||
try { process.pause(); } catch { errors++; }
|
||||
try { process.resume(); } catch { errors++; }
|
||||
expect(errors).toEqual(3);
|
||||
});
|
||||
|
||||
// ── autoStart option ───────────────────────────────────────────────────
|
||||
tap.test('autoStart should start process immediately', async () => {
|
||||
const state = new smartstate.Smartstate<'autoStart'>();
|
||||
const part = await state.getStatePart<{ v: number }>('autoStart', { v: 0 });
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => of(42),
|
||||
reducer: (s, v) => ({ v }),
|
||||
autoStart: true,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(process.status).toEqual('running');
|
||||
expect(part.getState()!.v).toEqual(42);
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -40,8 +40,10 @@ tap.test('should dispatch a state action', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const addFavourite = testStatePart.createAction<string>(async (statePart, payload) => {
|
||||
const currentState = statePart.getState();
|
||||
currentState.currentFavorites.push(payload);
|
||||
return currentState;
|
||||
return {
|
||||
...currentState,
|
||||
currentFavorites: [...currentState.currentFavorites, payload],
|
||||
};
|
||||
});
|
||||
testStatePart
|
||||
.waitUntilPresent((state) => {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartstate',
|
||||
version: '2.0.30',
|
||||
description: 'A package for handling and managing state in applications.'
|
||||
version: '2.3.0',
|
||||
description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './smartstate.classes.smartstate.js';
|
||||
export * from './smartstate.classes.statepart.js';
|
||||
export * from './smartstate.classes.stateaction.js';
|
||||
export * from './smartstate.classes.computed.js';
|
||||
export * from './smartstate.contextprovider.js';
|
||||
export * from './smartstate.classes.stateprocess.js';
|
||||
|
||||
17
ts/smartstate.classes.computed.ts
Normal file
17
ts/smartstate.classes.computed.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { combineLatest, map, distinctUntilChanged } 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)),
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
) as plugins.smartrx.rxjs.Observable<TResult>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { StatePart } from './smartstate.classes.statepart.js';
|
||||
import { computed } from './smartstate.classes.computed.js';
|
||||
|
||||
export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent';
|
||||
|
||||
@@ -11,17 +12,84 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
|
||||
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() {}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
await sp.notifyChange();
|
||||
} catch (err) {
|
||||
console.error(`Error flushing notification for state part:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* disposes all state parts and clears internal state
|
||||
*/
|
||||
public dispose(): void {
|
||||
for (const key of Object.keys(this.statePartMap)) {
|
||||
const part = this.statePartMap[key as StatePartNameType];
|
||||
if (part) {
|
||||
part.dispose();
|
||||
}
|
||||
}
|
||||
this.statePartMap = {} as any;
|
||||
this.pendingStatePartCreation.clear();
|
||||
this.pendingNotifications.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>(
|
||||
statePartNameArg: StatePartNameType,
|
||||
@@ -43,24 +111,22 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
`State part '${statePartNameArg}' already exists, but initMode is 'mandatory'`
|
||||
);
|
||||
case 'force':
|
||||
// Force mode: create new state part
|
||||
break; // Fall through to creation
|
||||
existingStatePart.dispose();
|
||||
break;
|
||||
case 'soft':
|
||||
case 'persistent':
|
||||
default:
|
||||
// Return existing state part
|
||||
return existingStatePart as StatePart<StatePartNameType, PayloadType>;
|
||||
}
|
||||
} else {
|
||||
// State part doesn't exist
|
||||
if (!initialArg) {
|
||||
if (initialArg === undefined) {
|
||||
throw new Error(
|
||||
`State part '${statePartNameArg}' does not exist and no initial state provided`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const creationPromise = this.createStatePart<PayloadType>(statePartNameArg, initialArg, initMode);
|
||||
const creationPromise = this.createStatePart<PayloadType>(statePartNameArg, initialArg!, initMode);
|
||||
this.pendingStatePartCreation.set(statePartNameArg, creationPromise);
|
||||
|
||||
try {
|
||||
@@ -73,9 +139,6 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
|
||||
/**
|
||||
* Creates a statepart
|
||||
* @param statePartName
|
||||
* @param initialPayloadArg
|
||||
* @param initMode
|
||||
*/
|
||||
private async createStatePart<PayloadType>(
|
||||
statePartName: StatePartNameType,
|
||||
@@ -89,23 +152,22 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
dbName: 'smartstate',
|
||||
storeName: statePartName,
|
||||
}
|
||||
: null
|
||||
: undefined
|
||||
);
|
||||
newState.smartstateRef = this;
|
||||
await newState.init();
|
||||
const currentState = newState.getState();
|
||||
|
||||
if (initMode === 'persistent' && currentState !== undefined) {
|
||||
// Persisted state exists - merge with defaults, persisted values take precedence
|
||||
await newState.setState({
|
||||
...initialPayloadArg,
|
||||
...currentState,
|
||||
});
|
||||
} else {
|
||||
// No persisted state or non-persistent mode
|
||||
await newState.setState(initialPayloadArg);
|
||||
}
|
||||
|
||||
this.statePartMap[statePartName] = newState;
|
||||
return newState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { StatePart } from './smartstate.classes.statepart.js';
|
||||
|
||||
/**
|
||||
* Context object passed to action definitions, enabling safe nested dispatch.
|
||||
* Use `context.dispatch()` to dispatch sub-actions inline (bypasses the mutation queue).
|
||||
* Direct `statePart.dispatchAction()` from within an action will deadlock.
|
||||
*/
|
||||
export interface IActionContext<TStateType> {
|
||||
dispatch<T>(action: StateAction<TStateType, T>, payload: T): Promise<TStateType>;
|
||||
}
|
||||
|
||||
export interface IActionDef<TStateType, TActionPayloadType> {
|
||||
(stateArg: StatePart<any, TStateType>, actionPayload: TActionPayloadType): Promise<TStateType>;
|
||||
(stateArg: StatePart<any, TStateType>, actionPayload: TActionPayloadType, context?: IActionContext<TStateType>): Promise<TStateType>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10,8 +18,8 @@ export interface IActionDef<TStateType, TActionPayloadType> {
|
||||
*/
|
||||
export class StateAction<TStateType, TActionPayloadType> {
|
||||
constructor(
|
||||
public statePartRef: StatePart<any, any>,
|
||||
public actionDef: IActionDef<TStateType, TActionPayloadType>
|
||||
public readonly statePartRef: StatePart<any, any>,
|
||||
public readonly actionDef: IActionDef<TStateType, TActionPayloadType>
|
||||
) {}
|
||||
|
||||
public trigger(payload: TActionPayloadType): Promise<TStateType> {
|
||||
|
||||
@@ -1,21 +1,61 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js';
|
||||
import { BehaviorSubject, Observable, shareReplay, takeUntil, distinctUntilChanged, interval } from 'rxjs';
|
||||
import { StateAction, type IActionDef, type IActionContext } from './smartstate.classes.stateaction.js';
|
||||
import type { Smartstate } from './smartstate.classes.smartstate.js';
|
||||
import { StateProcess, type IProcessOptions, type IScheduledActionOptions } from './smartstate.classes.stateprocess.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> {
|
||||
private static readonly MAX_NESTED_DISPATCH_DEPTH = 10;
|
||||
|
||||
public name: TStatePartName;
|
||||
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
|
||||
public stateStore: TStatePayload | undefined;
|
||||
private state = new BehaviorSubject<TStatePayload | undefined>(undefined);
|
||||
private stateStore: TStatePayload | undefined;
|
||||
public smartstateRef?: Smartstate<any>;
|
||||
private disposed = false;
|
||||
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
|
||||
|
||||
private mutationQueue: Promise<any> = Promise.resolve();
|
||||
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private webStoreOptions: plugins.webstore.IWebStoreOptions;
|
||||
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null; // Add WebStore instance
|
||||
private webStoreOptions: plugins.webstore.IWebStoreOptions | undefined;
|
||||
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null;
|
||||
|
||||
private middlewares: TMiddleware<TStatePayload>[] = [];
|
||||
|
||||
// Process tracking
|
||||
private processes: StateProcess<TStatePartName, TStatePayload, any>[] = [];
|
||||
|
||||
// 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) {
|
||||
this.name = nameArg;
|
||||
|
||||
// Initialize WebStore if webStoreOptions are provided
|
||||
if (webStoreOptionsArg) {
|
||||
this.webStoreOptions = webStoreOptionsArg;
|
||||
}
|
||||
@@ -44,22 +84,55 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
}
|
||||
|
||||
/**
|
||||
* sets the stateStore to the new state
|
||||
* @param newStateArg
|
||||
* adds a middleware that intercepts setState calls.
|
||||
* 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> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
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
|
||||
if (!this.validateState(newStateArg)) {
|
||||
if (!this.validateState(processedState)) {
|
||||
throw new Error(`Invalid state structure for state part '${this.name}'`);
|
||||
}
|
||||
|
||||
// Save to WebStore first to ensure atomicity - if save fails, memory state remains unchanged
|
||||
// Save to WebStore first to ensure atomicity
|
||||
if (this.webStore) {
|
||||
await this.webStore.set(String(this.name), newStateArg);
|
||||
await this.webStore.set(String(this.name), processedState);
|
||||
}
|
||||
|
||||
// Update in-memory state after successful persistence
|
||||
this.stateStore = newStateArg;
|
||||
this.stateStore = processedState;
|
||||
await this.notifyChange();
|
||||
|
||||
return this.stateStore;
|
||||
@@ -67,11 +140,8 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
|
||||
/**
|
||||
* Validates state structure - can be overridden for custom validation
|
||||
* @param stateArg
|
||||
*/
|
||||
protected validateState(stateArg: any): stateArg is TStatePayload {
|
||||
// Basic validation - ensure state is not null/undefined
|
||||
// Subclasses can override for more specific validation
|
||||
return stateArg !== null && stateArg !== undefined;
|
||||
}
|
||||
|
||||
@@ -79,64 +149,107 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
* notifies of a change on the state
|
||||
*/
|
||||
public async notifyChange() {
|
||||
if (!this.stateStore) {
|
||||
const snapshot = this.stateStore;
|
||||
if (snapshot === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If inside a batch, defer the notification
|
||||
if (this.smartstateRef?.isBatching) {
|
||||
this.smartstateRef.registerPendingNotification(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const createStateHash = async (stateArg: any) => {
|
||||
return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
|
||||
};
|
||||
const currentHash = await createStateHash(this.stateStore);
|
||||
if (
|
||||
this.lastStateNotificationPayloadHash &&
|
||||
currentHash === this.lastStateNotificationPayloadHash
|
||||
) {
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
const currentHash = await createStateHash(snapshot);
|
||||
if (
|
||||
this.lastStateNotificationPayloadHash &&
|
||||
currentHash === this.lastStateNotificationPayloadHash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
/**
|
||||
* creates a cumulative notification by adding a change notification at the end of the call stack;
|
||||
* creates a cumulative notification by adding a change notification at the end of the call stack
|
||||
*/
|
||||
public notifyChangeCumulative() {
|
||||
// Debounce: clear any pending notification
|
||||
if (this.pendingCumulativeNotification) {
|
||||
clearTimeout(this.pendingCumulativeNotification);
|
||||
}
|
||||
|
||||
this.pendingCumulativeNotification = setTimeout(async () => {
|
||||
this.pendingCumulativeNotification = setTimeout(() => {
|
||||
this.pendingCumulativeNotification = null;
|
||||
if (this.stateStore) {
|
||||
await this.notifyChange();
|
||||
if (this.stateStore !== undefined) {
|
||||
this.notifyChange().catch((err) => {
|
||||
console.error(`notifyChangeCumulative failed for '${this.name}':`, err);
|
||||
});
|
||||
}
|
||||
}, 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>(
|
||||
selectorFn?: (state: TStatePayload) => T
|
||||
selectorFn?: (state: TStatePayload) => T,
|
||||
options?: { signal?: AbortSignal }
|
||||
): plugins.smartrx.rxjs.Observable<T> {
|
||||
if (!selectorFn) {
|
||||
selectorFn = (state: TStatePayload) => <T>(<any>state);
|
||||
const hasSignal = options?.signal != null;
|
||||
|
||||
// Check memoization cache (only for non-signal selects)
|
||||
if (!hasSignal) {
|
||||
if (!selectorFn) {
|
||||
if (this.defaultSelectObservable) {
|
||||
return this.defaultSelectObservable as unknown as plugins.smartrx.rxjs.Observable<T>;
|
||||
}
|
||||
} else if (this.selectorCache.has(selectorFn)) {
|
||||
return this.selectorCache.get(selectorFn)!;
|
||||
}
|
||||
}
|
||||
const mapped = this.state.pipe(
|
||||
plugins.smartrx.rxjs.ops.startWith(this.getState()),
|
||||
|
||||
const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
|
||||
const SELECTOR_ERROR: unique symbol = Symbol('selector-error');
|
||||
|
||||
let mapped = this.state.pipe(
|
||||
plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined),
|
||||
plugins.smartrx.rxjs.ops.map((stateArg) => {
|
||||
try {
|
||||
return selectorFn(stateArg);
|
||||
return effectiveSelectorFn(stateArg);
|
||||
} catch (e) {
|
||||
console.error(`Selector error in state part '${this.name}':`, e);
|
||||
return undefined;
|
||||
return SELECTOR_ERROR as any;
|
||||
}
|
||||
})
|
||||
}),
|
||||
plugins.smartrx.rxjs.ops.filter((v: any) => v !== SELECTOR_ERROR),
|
||||
distinctUntilChanged((a: any, b: any) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,30 +262,72 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatches an action on the statepart level
|
||||
* creates a depth-tracked action context for safe nested dispatch.
|
||||
* Using context.dispatch() within an actionDef bypasses the mutation queue
|
||||
* and executes the sub-action inline, preventing deadlocks.
|
||||
*/
|
||||
public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
|
||||
await this.cumulativeDeferred.promise;
|
||||
const newState = await stateAction.actionDef(this, actionPayload);
|
||||
await this.setState(newState);
|
||||
return this.getState();
|
||||
private createActionContext(depth: number): IActionContext<TStatePayload> {
|
||||
const self = this;
|
||||
return {
|
||||
dispatch: async <U>(action: StateAction<TStatePayload, U>, payload: U): Promise<TStatePayload> => {
|
||||
if (depth >= StatePart.MAX_NESTED_DISPATCH_DEPTH) {
|
||||
throw new Error(
|
||||
`Maximum nested action dispatch depth (${StatePart.MAX_NESTED_DISPATCH_DEPTH}) exceeded. ` +
|
||||
`Check for circular action dispatches.`
|
||||
);
|
||||
}
|
||||
const innerContext = self.createActionContext(depth + 1);
|
||||
const newState = await action.actionDef(self, payload, innerContext);
|
||||
return self.applyState(newState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* waits until a certain part of the state becomes available
|
||||
* @param selectorFn
|
||||
* @param timeoutMs - optional timeout in milliseconds to prevent indefinite waiting
|
||||
* dispatches an action on the statepart level
|
||||
*/
|
||||
public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
await this.cumulativeDeferred.promise;
|
||||
const execute = async () => {
|
||||
const context = this.createActionContext(0);
|
||||
const newState = await stateAction.actionDef(this, actionPayload, context);
|
||||
return this.applyState(newState);
|
||||
};
|
||||
return this.mutationQueue = this.mutationQueue.then(execute, execute);
|
||||
}
|
||||
|
||||
/**
|
||||
* waits until a certain part of the state becomes available.
|
||||
* supports optional timeout and AbortSignal.
|
||||
*/
|
||||
public async waitUntilPresent<T = TStatePayload>(
|
||||
selectorFn?: (state: TStatePayload) => T,
|
||||
timeoutMs?: number
|
||||
optionsOrTimeout?: number | { timeoutMs?: number; signal?: AbortSignal }
|
||||
): 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 selectedObservable = this.select(selectorFn);
|
||||
let resolved = false;
|
||||
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
}
|
||||
|
||||
const subscription = selectedObservable.subscribe((value) => {
|
||||
if (value && !resolved) {
|
||||
if (value !== undefined && value !== null && !resolved) {
|
||||
resolved = true;
|
||||
done.resolve(value);
|
||||
}
|
||||
@@ -189,12 +344,29 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Handle abort signal
|
||||
const abortHandler = signal ? () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
subscription.unsubscribe();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
done.reject(new Error('Aborted'));
|
||||
}
|
||||
} : undefined;
|
||||
|
||||
if (signal && abortHandler) {
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await done.promise;
|
||||
return result;
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,4 +380,78 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
this.cumulativeDeferred.addPromise(resultPromise);
|
||||
await this.setState(await resultPromise);
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a managed, pausable process that ties an observable producer to state updates
|
||||
*/
|
||||
public createProcess<TProducerValue>(
|
||||
options: IProcessOptions<TStatePayload, TProducerValue>
|
||||
): StateProcess<TStatePartName, TStatePayload, TProducerValue> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
const process = new StateProcess<TStatePartName, TStatePayload, TProducerValue>(this, options);
|
||||
this.processes.push(process);
|
||||
if (options.autoStart) {
|
||||
process.start();
|
||||
}
|
||||
return process;
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a process that dispatches an action on a recurring interval
|
||||
*/
|
||||
public createScheduledAction<TActionPayload>(
|
||||
options: IScheduledActionOptions<TStatePayload, TActionPayload>
|
||||
): StateProcess<TStatePartName, TStatePayload, number> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
const process = new StateProcess<TStatePartName, TStatePayload, number>(this, {
|
||||
producer: () => interval(options.intervalMs),
|
||||
sideEffect: async () => {
|
||||
await options.action.trigger(options.payload);
|
||||
},
|
||||
autoPause: options.autoPause ?? false,
|
||||
});
|
||||
this.processes.push(process);
|
||||
process.start();
|
||||
return process;
|
||||
}
|
||||
|
||||
/** @internal — called by StateProcess.dispose() to remove itself */
|
||||
public _removeProcess(process: StateProcess<any, any, any>): void {
|
||||
const idx = this.processes.indexOf(process);
|
||||
if (idx !== -1) {
|
||||
this.processes.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* disposes the state part, completing the Subject and cleaning up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
|
||||
// Dispose all processes first
|
||||
for (const process of [...this.processes]) {
|
||||
process.dispose();
|
||||
}
|
||||
this.processes.length = 0;
|
||||
|
||||
this.state.complete();
|
||||
this.mutationQueue = Promise.resolve() as any;
|
||||
|
||||
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;
|
||||
this.stateStore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
177
ts/smartstate.classes.stateprocess.ts
Normal file
177
ts/smartstate.classes.stateprocess.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
|
||||
import type { StatePart } from './smartstate.classes.statepart.js';
|
||||
import type { StateAction } from './smartstate.classes.stateaction.js';
|
||||
|
||||
export type TProcessStatus = 'idle' | 'running' | 'paused' | 'disposed';
|
||||
export type TAutoPause = 'visibility' | Observable<boolean> | false;
|
||||
|
||||
export interface IProcessOptions<TStatePayload, TProducerValue> {
|
||||
producer: () => Observable<TProducerValue>;
|
||||
reducer: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
autoPause?: TAutoPause;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
export interface IScheduledActionOptions<TStatePayload, TActionPayload> {
|
||||
action: StateAction<TStatePayload, TActionPayload>;
|
||||
payload: TActionPayload;
|
||||
intervalMs: number;
|
||||
autoPause?: TAutoPause;
|
||||
}
|
||||
|
||||
/**
|
||||
* creates an Observable<boolean> from the Page Visibility API.
|
||||
* emits true when the page is visible, false when hidden.
|
||||
* in Node.js (no document), returns an always-true observable.
|
||||
*/
|
||||
function createVisibilityObservable(): Observable<boolean> {
|
||||
if (typeof document === 'undefined') {
|
||||
return of(true);
|
||||
}
|
||||
return new Observable<boolean>((subscriber) => {
|
||||
subscriber.next(!document.hidden);
|
||||
const handler = () => subscriber.next(!document.hidden);
|
||||
document.addEventListener('visibilitychange', handler);
|
||||
return () => document.removeEventListener('visibilitychange', handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* a managed, pausable process that ties an observable producer to state updates.
|
||||
* supports lifecycle management (start/pause/resume/dispose) and auto-pause signals.
|
||||
*/
|
||||
export class StateProcess<TStatePartName, TStatePayload, TProducerValue> {
|
||||
private readonly statePartRef: StatePart<TStatePartName, TStatePayload>;
|
||||
private readonly producerFn: () => Observable<TProducerValue>;
|
||||
private readonly reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
private readonly sideEffect?: (value: TProducerValue) => Promise<void> | void;
|
||||
private readonly autoPauseOption: TAutoPause;
|
||||
|
||||
private statusSubject = new BehaviorSubject<TProcessStatus>('idle');
|
||||
private producerSubscription: Subscription | null = null;
|
||||
private autoPauseSubscription: Subscription | null = null;
|
||||
private processingQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
statePartRef: StatePart<TStatePartName, TStatePayload>,
|
||||
options: {
|
||||
producer: () => Observable<TProducerValue>;
|
||||
reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
sideEffect?: (value: TProducerValue) => Promise<void> | void;
|
||||
autoPause?: TAutoPause;
|
||||
}
|
||||
) {
|
||||
this.statePartRef = statePartRef;
|
||||
this.producerFn = options.producer;
|
||||
this.reducer = options.reducer;
|
||||
this.sideEffect = options.sideEffect;
|
||||
this.autoPauseOption = options.autoPause ?? false;
|
||||
}
|
||||
|
||||
public get status(): TProcessStatus {
|
||||
return this.statusSubject.getValue();
|
||||
}
|
||||
|
||||
public get status$(): Observable<TProcessStatus> {
|
||||
return this.statusSubject.asObservable();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot start a disposed process');
|
||||
}
|
||||
if (this.status === 'running') return;
|
||||
this.statusSubject.next('running');
|
||||
this.subscribeProducer();
|
||||
this.setupAutoPause();
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot pause a disposed process');
|
||||
}
|
||||
if (this.status !== 'running') return;
|
||||
this.statusSubject.next('paused');
|
||||
this.unsubscribeProducer();
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot resume a disposed process');
|
||||
}
|
||||
if (this.status !== 'paused') return;
|
||||
this.statusSubject.next('running');
|
||||
this.subscribeProducer();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.status === 'disposed') return;
|
||||
this.unsubscribeProducer();
|
||||
this.teardownAutoPause();
|
||||
this.statusSubject.next('disposed');
|
||||
this.statusSubject.complete();
|
||||
this.statePartRef._removeProcess(this);
|
||||
}
|
||||
|
||||
private subscribeProducer(): void {
|
||||
this.unsubscribeProducer();
|
||||
const source = this.producerFn();
|
||||
this.producerSubscription = source.subscribe({
|
||||
next: (value) => {
|
||||
// Queue value processing to ensure each reads fresh state after the previous completes
|
||||
this.processingQueue = this.processingQueue.then(async () => {
|
||||
try {
|
||||
if (this.sideEffect) {
|
||||
await this.sideEffect(value);
|
||||
} else if (this.reducer) {
|
||||
const currentState = this.statePartRef.getState();
|
||||
if (currentState !== undefined) {
|
||||
await this.statePartRef.setState(this.reducer(currentState, value));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('StateProcess value handling error:', err);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('StateProcess producer error:', err);
|
||||
if (this.status === 'running') {
|
||||
this.statusSubject.next('paused');
|
||||
this.unsubscribeProducer();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private unsubscribeProducer(): void {
|
||||
if (this.producerSubscription) {
|
||||
this.producerSubscription.unsubscribe();
|
||||
this.producerSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setupAutoPause(): void {
|
||||
this.teardownAutoPause();
|
||||
if (!this.autoPauseOption) return;
|
||||
|
||||
const signal$ = this.autoPauseOption === 'visibility'
|
||||
? createVisibilityObservable()
|
||||
: this.autoPauseOption;
|
||||
|
||||
this.autoPauseSubscription = signal$.subscribe((active) => {
|
||||
if (!active && this.status === 'running') {
|
||||
this.pause();
|
||||
} else if (active && this.status === 'paused') {
|
||||
this.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private teardownAutoPause(): void {
|
||||
if (this.autoPauseSubscription) {
|
||||
this.autoPauseSubscription.unsubscribe();
|
||||
this.autoPauseSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
ts/smartstate.contextprovider.ts
Normal file
61
ts/smartstate.contextprovider.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user