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