Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c88033ff26 | |||
| a62fa83afc | |||
| 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": {
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,5 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-30 - 2.3.1 - fix(types,testing)
|
||||
tighten action context typing and update tests for stricter TypeScript checks
|
||||
|
||||
- enable noImplicitAny in TypeScript configuration and remove the build flag that allowed implicit any
|
||||
- require the action context parameter in IActionDef to reflect actual action usage
|
||||
- update tests to use the tstest tapbundle import and add explicit guards for possibly undefined state access
|
||||
- refresh dependency versions and remove the deprecated tapbundle dev dependency
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Generated
-16523
File diff suppressed because it is too large
Load Diff
+21
-16
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@push.rocks/smartstate",
|
||||
"version": "2.0.30",
|
||||
"version": "2.3.1",
|
||||
"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",
|
||||
@@ -10,24 +10,22 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && tsbundle npm)",
|
||||
"build": "(tsbuild tsfolders && tsbundle npm)",
|
||||
"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",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^25.2.0"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@types/node": "^25.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smarthash": "^3.2.6",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smarthash": "^3.2.7",
|
||||
"@push.rocks/smartjson": "^6.0.1",
|
||||
"@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 +36,8 @@
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"license",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
@@ -54,12 +53,18 @@
|
||||
"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": {
|
||||
"type": "git",
|
||||
"url": "https://code.foss.global/push.rocks/smartstate.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
"packageManager": "pnpm@10.28.2"
|
||||
}
|
||||
|
||||
Generated
+8005
File diff suppressed because it is too large
Load Diff
+46
-29
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+883
-37
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
+8
-6
@@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartstate from '../ts/index.js';
|
||||
|
||||
type TMyStateParts = 'testStatePart';
|
||||
@@ -39,9 +39,11 @@ tap.test('should select something', async () => {
|
||||
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;
|
||||
const currentState = statePart.getState()!;
|
||||
return {
|
||||
...currentState,
|
||||
currentFavorites: [...currentState.currentFavorites, payload],
|
||||
};
|
||||
});
|
||||
testStatePart
|
||||
.waitUntilPresent((state) => {
|
||||
@@ -49,9 +51,9 @@ tap.test('should dispatch a state action', async (tools) => {
|
||||
})
|
||||
.then(() => {
|
||||
done.resolve();
|
||||
});
|
||||
});
|
||||
await testStatePart.dispatchAction(addFavourite, 'my favourite things');
|
||||
expect(testStatePart.getState().currentFavorites).toContain('my favourite things');
|
||||
expect(testStatePart.getState()!.currentFavorites).toContain('my favourite things');
|
||||
await done.promise;
|
||||
});
|
||||
|
||||
|
||||
@@ -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.1',
|
||||
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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -5,8 +5,10 @@
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noImplicitAny": true,
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user