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
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
11
package.json
11
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "@push.rocks/smartstate",
|
||||
"version": "2.0.31",
|
||||
"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",
|
||||
@@ -22,7 +22,6 @@
|
||||
"@types/node": "^25.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smarthash": "^3.2.6",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
@@ -54,7 +53,13 @@
|
||||
"state selection",
|
||||
"state notification",
|
||||
"asynchronous state",
|
||||
"cumulative notification"
|
||||
"cumulative notification",
|
||||
"middleware",
|
||||
"computed state",
|
||||
"batch updates",
|
||||
"context protocol",
|
||||
"web components",
|
||||
"AbortSignal"
|
||||
],
|
||||
"homepage": "https://code.foss.global/push.rocks/smartstate",
|
||||
"repository": {
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -8,9 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@push.rocks/lik':
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
'@push.rocks/smarthash':
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6
|
||||
|
||||
@@ -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,50 +8,67 @@
|
||||
- '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.30)
|
||||
## Dependency Versions (v2.0.31)
|
||||
- @git.zone/tsbuild: ^4.1.2
|
||||
- @git.zone/tsbundle: ^2.9.0
|
||||
- @git.zone/tsrun: ^2.0.1
|
||||
|
||||
465
readme.md
465
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartstate
|
||||
|
||||
A powerful TypeScript library for elegant state management using RxJS and reactive programming patterns 🚀
|
||||
A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -8,306 +8,311 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
## Install
|
||||
|
||||
To install `@push.rocks/smartstate`, you can use pnpm, npm, or yarn:
|
||||
```bash
|
||||
pnpm install @push.rocks/smartstate --save
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install @push.rocks/smartstate --save
|
||||
|
||||
# Using npm
|
||||
npm install @push.rocks/smartstate --save
|
||||
|
||||
# Using yarn
|
||||
yarn add @push.rocks/smartstate
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The `@push.rocks/smartstate` library provides an elegant way to handle state within your JavaScript or TypeScript projects, leveraging the power of Reactive Extensions (RxJS) and a structured state management strategy.
|
||||
|
||||
### Getting Started
|
||||
|
||||
Import the necessary components from the library:
|
||||
### Quick Start
|
||||
|
||||
```typescript
|
||||
import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
|
||||
import { Smartstate } from '@push.rocks/smartstate';
|
||||
|
||||
// 1. Define your state part names
|
||||
type AppParts = 'user' | 'settings';
|
||||
|
||||
// 2. Create the root instance
|
||||
const state = new Smartstate<AppParts>();
|
||||
|
||||
// 3. Create state parts with initial values
|
||||
const userState = await state.getStatePart<{ name: string; loggedIn: boolean }>('user', {
|
||||
name: '',
|
||||
loggedIn: false,
|
||||
});
|
||||
|
||||
// 4. Subscribe to changes
|
||||
userState.select((s) => s.name).subscribe((name) => {
|
||||
console.log('Name changed:', name);
|
||||
});
|
||||
|
||||
// 5. Update state
|
||||
await userState.setState({ name: 'Alice', loggedIn: true });
|
||||
```
|
||||
|
||||
### Creating a SmartState Instance
|
||||
### State Parts & Init Modes
|
||||
|
||||
`Smartstate` acts as the container for your state parts. Think of it as the root of your state management structure:
|
||||
State parts are isolated, typed units of state. Create them with `getStatePart()`:
|
||||
|
||||
```typescript
|
||||
const myAppSmartState = new Smartstate<YourStatePartNamesEnum>();
|
||||
const part = await state.getStatePart<IMyState>(name, initialState, initMode);
|
||||
```
|
||||
|
||||
### Understanding Init Modes
|
||||
| Init Mode | Behavior |
|
||||
|-----------|----------|
|
||||
| `'soft'` (default) | Returns existing if found, creates new otherwise |
|
||||
| `'mandatory'` | Throws if state part already exists |
|
||||
| `'force'` | Always creates new, overwrites existing |
|
||||
| `'persistent'` | Like `'soft'` but persists to IndexedDB via WebStore |
|
||||
|
||||
When creating state parts, you can specify different initialization modes:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `'soft'` | Default. Returns existing state part if it exists, creates new if not |
|
||||
| `'mandatory'` | Requires state part to not exist, throws error if it does |
|
||||
| `'force'` | Always creates new state part, overwriting any existing one |
|
||||
| `'persistent'` | Like 'soft' but with WebStore persistence using IndexedDB |
|
||||
|
||||
### Defining State Parts
|
||||
|
||||
State parts represent separable sections of your state, making it easier to manage and modularize. Define state part names using either enums or string literal types:
|
||||
#### Persistent State
|
||||
|
||||
```typescript
|
||||
// Option 1: Using enums
|
||||
enum AppStateParts {
|
||||
UserState = 'UserState',
|
||||
SettingsState = 'SettingsState'
|
||||
}
|
||||
|
||||
// Option 2: Using string literal types (simpler approach)
|
||||
type AppStateParts = 'UserState' | 'SettingsState';
|
||||
const settings = await state.getStatePart('settings', { theme: 'dark' }, 'persistent');
|
||||
// Automatically saved to IndexedDB. On next app load, persisted values override defaults.
|
||||
```
|
||||
|
||||
Create a state part within your `Smartstate` instance:
|
||||
### Selecting State
|
||||
|
||||
`select()` returns an RxJS Observable that emits the current value immediately and on every change:
|
||||
|
||||
```typescript
|
||||
interface IUserState {
|
||||
isLoggedIn: boolean;
|
||||
username?: string;
|
||||
}
|
||||
// Full state
|
||||
userState.select().subscribe((state) => console.log(state));
|
||||
|
||||
const userStatePart = await myAppSmartState.getStatePart<IUserState>(
|
||||
AppStateParts.UserState,
|
||||
{ isLoggedIn: false }, // Initial state
|
||||
'soft' // Init mode (optional, defaults to 'soft')
|
||||
// Derived value via selector
|
||||
userState.select((s) => s.name).subscribe((name) => console.log(name));
|
||||
```
|
||||
|
||||
Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers.
|
||||
|
||||
#### AbortSignal Support
|
||||
|
||||
Clean up subscriptions without manual `unsubscribe()`:
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
|
||||
userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => {
|
||||
console.log(name); // stops receiving when aborted
|
||||
});
|
||||
|
||||
// Later: clean up
|
||||
controller.abort();
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
Actions provide controlled, named state mutations:
|
||||
|
||||
```typescript
|
||||
const login = userState.createAction<{ name: string }>(async (statePart, payload) => {
|
||||
return { ...statePart.getState(), name: payload.name, loggedIn: true };
|
||||
});
|
||||
|
||||
// Two equivalent ways to dispatch:
|
||||
await login.trigger({ name: 'Alice' });
|
||||
await userState.dispatchAction(login, { name: 'Alice' });
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
Intercept every `setState()` call to transform, validate, or reject state changes:
|
||||
|
||||
```typescript
|
||||
// Logging middleware
|
||||
userState.addMiddleware((newState, oldState) => {
|
||||
console.log('State changing from', oldState, 'to', newState);
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Validation middleware — throw to reject the change
|
||||
userState.addMiddleware((newState) => {
|
||||
if (!newState.name) throw new Error('Name is required');
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Transform middleware
|
||||
userState.addMiddleware((newState) => {
|
||||
return { ...newState, name: newState.name.trim() };
|
||||
});
|
||||
|
||||
// Removal — addMiddleware returns a dispose function
|
||||
const remove = userState.addMiddleware(myMiddleware);
|
||||
remove(); // middleware no longer runs
|
||||
```
|
||||
|
||||
Middleware runs sequentially in insertion order. If any middleware throws, the state is unchanged (atomic).
|
||||
|
||||
### Computed / Derived State
|
||||
|
||||
Derive reactive values from one or more state parts:
|
||||
|
||||
```typescript
|
||||
import { computed } from '@push.rocks/smartstate';
|
||||
|
||||
const userState = await state.getStatePart('user', { firstName: 'Jane', lastName: 'Doe' });
|
||||
const settingsState = await state.getStatePart('settings', { locale: 'en' });
|
||||
|
||||
// Standalone function
|
||||
const greeting$ = computed(
|
||||
[userState, settingsState],
|
||||
(user, settings) => `Hello, ${user.firstName} (${settings.locale})`,
|
||||
);
|
||||
|
||||
greeting$.subscribe((msg) => console.log(msg));
|
||||
// => "Hello, Jane (en)"
|
||||
|
||||
// Also available as a method on Smartstate:
|
||||
const greeting2$ = state.computed([userState, settingsState], (user, settings) => /* ... */);
|
||||
```
|
||||
|
||||
### Subscribing to State Changes
|
||||
Computed observables are **lazy** — they only subscribe to sources when someone subscribes to them.
|
||||
|
||||
Subscribe to changes in a state part to perform actions accordingly:
|
||||
### Batch Updates
|
||||
|
||||
Update multiple state parts without intermediate notifications:
|
||||
|
||||
```typescript
|
||||
// The select() method automatically filters out undefined states
|
||||
userStatePart.select().subscribe((currentState) => {
|
||||
console.log(`User Logged In: ${currentState.isLoggedIn}`);
|
||||
const partA = await state.getStatePart('a', { value: 1 });
|
||||
const partB = await state.getStatePart('b', { value: 2 });
|
||||
|
||||
// Subscribers see no updates during the batch — only after it completes
|
||||
await state.batch(async () => {
|
||||
await partA.setState({ value: 10 });
|
||||
await partB.setState({ value: 20 });
|
||||
// Notifications are deferred here
|
||||
});
|
||||
```
|
||||
// Both subscribers now fire with their new values
|
||||
|
||||
Select a specific part of your state with a selector function:
|
||||
|
||||
```typescript
|
||||
userStatePart.select(state => state.username).subscribe((username) => {
|
||||
if (username) {
|
||||
console.log(`Current user: ${username}`);
|
||||
}
|
||||
// Nested batches are supported — flush happens at the outermost level
|
||||
await state.batch(async () => {
|
||||
await partA.setState({ value: 100 });
|
||||
await state.batch(async () => {
|
||||
await partB.setState({ value: 200 });
|
||||
});
|
||||
// Still deferred
|
||||
});
|
||||
// Now both fire
|
||||
```
|
||||
|
||||
### Modifying State with Actions
|
||||
### Waiting for State
|
||||
|
||||
Create actions to modify the state in a controlled manner:
|
||||
Wait for a specific state condition to be met:
|
||||
|
||||
```typescript
|
||||
interface ILoginPayload {
|
||||
username: string;
|
||||
}
|
||||
// Wait for any truthy state
|
||||
const currentState = await userState.waitUntilPresent();
|
||||
|
||||
const loginUserAction = userStatePart.createAction<ILoginPayload>(async (statePart, payload) => {
|
||||
return { ...statePart.getState(), isLoggedIn: true, username: payload.username };
|
||||
});
|
||||
// Wait for a specific condition
|
||||
const name = await userState.waitUntilPresent((s) => s.name || undefined);
|
||||
|
||||
// Dispatch the action to update the state
|
||||
const newState = await loginUserAction.trigger({ username: 'johnDoe' });
|
||||
```
|
||||
// With timeout (backward compatible)
|
||||
const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
|
||||
|
||||
### Dispatching Actions
|
||||
|
||||
There are two ways to dispatch actions:
|
||||
|
||||
```typescript
|
||||
// Method 1: Using trigger on the action (returns promise)
|
||||
const newState = await loginUserAction.trigger({ username: 'johnDoe' });
|
||||
|
||||
// Method 2: Using dispatchAction on the state part (returns promise)
|
||||
const newState = await userStatePart.dispatchAction(loginUserAction, { username: 'johnDoe' });
|
||||
```
|
||||
|
||||
Both methods return a Promise with the new state payload.
|
||||
|
||||
### Additional State Methods
|
||||
|
||||
`StatePart` provides several useful methods for state management:
|
||||
|
||||
```typescript
|
||||
// Get current state (may be undefined initially)
|
||||
const currentState = userStatePart.getState();
|
||||
if (currentState) {
|
||||
console.log('Current user:', currentState.username);
|
||||
}
|
||||
|
||||
// Wait for state to be present
|
||||
await userStatePart.waitUntilPresent();
|
||||
|
||||
// Wait for a specific property to be present
|
||||
await userStatePart.waitUntilPresent(state => state.username);
|
||||
|
||||
// Wait with a timeout (throws error if condition not met within timeout)
|
||||
// With AbortSignal
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
await userStatePart.waitUntilPresent(state => state.username, 5000); // 5 second timeout
|
||||
} catch (error) {
|
||||
console.error('Timed out waiting for username');
|
||||
const name = await userState.waitUntilPresent(
|
||||
(s) => s.name || undefined,
|
||||
{ timeoutMs: 5000, signal: controller.signal },
|
||||
);
|
||||
} catch (e) {
|
||||
// 'Aborted' or timeout error
|
||||
}
|
||||
|
||||
// Setup initial state with async operations
|
||||
await userStatePart.stateSetup(async (statePart) => {
|
||||
const userData = await fetchUserData();
|
||||
return { ...statePart.getState(), ...userData };
|
||||
});
|
||||
|
||||
// Defer notification to end of call stack (debounced)
|
||||
userStatePart.notifyChangeCumulative();
|
||||
```
|
||||
|
||||
### Persistent State with WebStore
|
||||
### Context Protocol Bridge (Web Components)
|
||||
|
||||
`Smartstate` supports persistent states using WebStore (IndexedDB-based storage), allowing you to maintain state across sessions:
|
||||
Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md):
|
||||
|
||||
```typescript
|
||||
const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>(
|
||||
AppStateParts.SettingsState,
|
||||
{ theme: 'light' }, // Initial/default state
|
||||
'persistent' // Mode
|
||||
import { attachContextProvider } from '@push.rocks/smartstate';
|
||||
|
||||
// Define a context key
|
||||
const themeContext = Symbol('theme');
|
||||
|
||||
// Attach a provider to a DOM element
|
||||
const cleanup = attachContextProvider(myElement, {
|
||||
context: themeContext,
|
||||
statePart: settingsState,
|
||||
selectorFn: (s) => s.theme, // optional: provide derived value
|
||||
});
|
||||
|
||||
// Any descendant can request this context:
|
||||
myElement.dispatchEvent(
|
||||
new CustomEvent('context-request', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
context: themeContext,
|
||||
callback: (theme) => console.log('Theme:', theme),
|
||||
subscribe: true, // receive updates on state changes
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Cleanup when done
|
||||
cleanup();
|
||||
```
|
||||
|
||||
Persistent state automatically:
|
||||
- Saves state changes to IndexedDB
|
||||
- Restores state on application restart
|
||||
- Merges persisted values with defaults (persisted values take precedence)
|
||||
- Ensures atomic writes (persistence happens before memory update)
|
||||
This works with Lit's `@consume()` decorator, FAST, or any framework implementing the Context Protocol.
|
||||
|
||||
### State Validation
|
||||
|
||||
`Smartstate` includes built-in state validation to ensure data integrity:
|
||||
Built-in null/undefined validation. Extend for custom rules:
|
||||
|
||||
```typescript
|
||||
// Basic validation (built-in) ensures state is not null or undefined
|
||||
await userStatePart.setState(null); // Throws error: Invalid state structure
|
||||
|
||||
// Custom validation by extending StatePart
|
||||
class ValidatedStatePart<T> extends StatePart<string, T> {
|
||||
class ValidatedPart<T> extends StatePart<string, T> {
|
||||
protected validateState(stateArg: any): stateArg is T {
|
||||
return super.validateState(stateArg) && /* your validation */;
|
||||
return super.validateState(stateArg) && typeof stateArg.name === 'string';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
### Performance Features
|
||||
|
||||
`Smartstate` includes advanced performance optimizations:
|
||||
- **SHA256 Change Detection** — identical state values don't trigger notifications, even with different object references
|
||||
- **Selector Memoization** — `select(fn)` caches observables by function reference, sharing one upstream subscription across all subscribers
|
||||
- **Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification
|
||||
- **Concurrent Safety** — simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation
|
||||
- **Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency
|
||||
- **Batch Deferred Notifications** — `batch()` suppresses all notifications until the batch completes
|
||||
|
||||
- **🔒 Async State Hash Detection**: Uses SHA256 hashing to detect actual state changes, preventing unnecessary notifications when state values haven't truly changed
|
||||
- **🚫 Duplicate Prevention**: Identical state updates are automatically filtered out
|
||||
- **📦 Cumulative Notifications**: Batch multiple state changes into a single notification using `notifyChangeCumulative()` with automatic debouncing
|
||||
- **🎯 Selective Subscriptions**: Use selectors to subscribe only to specific state properties
|
||||
- **✨ Undefined State Filtering**: The `select()` method automatically filters out undefined states
|
||||
- **⚡ Concurrent Access Safety**: Prevents race conditions when multiple calls request the same state part simultaneously
|
||||
## API Reference
|
||||
|
||||
### RxJS Integration
|
||||
### `Smartstate<T>`
|
||||
|
||||
`Smartstate` leverages RxJS for reactive state management:
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getStatePart(name, initial?, initMode?)` | Get or create a state part |
|
||||
| `batch(fn)` | Batch updates, defer notifications |
|
||||
| `computed(sources, fn)` | Create computed observable |
|
||||
| `isBatching` | Whether a batch is active |
|
||||
|
||||
```typescript
|
||||
// State is exposed as an RxJS Subject
|
||||
const stateObservable = userStatePart.select();
|
||||
### `StatePart<TName, TPayload>`
|
||||
|
||||
// Automatically starts with current state value
|
||||
stateObservable.subscribe((state) => {
|
||||
console.log('Current state:', state);
|
||||
});
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getState()` | Get current state (or undefined) |
|
||||
| `setState(newState)` | Set state (runs middleware, validates, persists, notifies) |
|
||||
| `select(selectorFn?, options?)` | Subscribe to state changes |
|
||||
| `createAction(actionDef)` | Create a named action |
|
||||
| `dispatchAction(action, payload)` | Dispatch an action |
|
||||
| `addMiddleware(fn)` | Add middleware, returns removal function |
|
||||
| `waitUntilPresent(selectorFn?, options?)` | Wait for state condition |
|
||||
| `notifyChange()` | Manually trigger notification |
|
||||
| `notifyChangeCumulative()` | Debounced notification |
|
||||
| `stateSetup(fn)` | Async state initialization |
|
||||
|
||||
// Use selectors for specific properties
|
||||
userStatePart.select(state => state.username)
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter(username => username !== undefined)
|
||||
)
|
||||
.subscribe(username => {
|
||||
console.log('Username changed:', username);
|
||||
});
|
||||
```
|
||||
### `StateAction<TState, TPayload>`
|
||||
|
||||
### Complete Example
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `trigger(payload)` | Dispatch the action |
|
||||
|
||||
Here's a comprehensive example showcasing the power of `@push.rocks/smartstate`:
|
||||
### Standalone Functions
|
||||
|
||||
```typescript
|
||||
import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
|
||||
|
||||
// Define your state structure
|
||||
type AppStateParts = 'user' | 'settings' | 'cart';
|
||||
|
||||
interface IUserState {
|
||||
isLoggedIn: boolean;
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface ICartState {
|
||||
items: Array<{ id: string; quantity: number }>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Create the smartstate instance
|
||||
const appState = new Smartstate<AppStateParts>();
|
||||
|
||||
// Initialize state parts
|
||||
const userState = await appState.getStatePart<IUserState>('user', {
|
||||
isLoggedIn: false
|
||||
});
|
||||
|
||||
const cartState = await appState.getStatePart<ICartState>('cart', {
|
||||
items: [],
|
||||
total: 0
|
||||
}, 'persistent'); // Persists across sessions
|
||||
|
||||
// Create actions
|
||||
const loginAction = userState.createAction<{ username: string; email: string }>(
|
||||
async (statePart, payload) => {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
return {
|
||||
isLoggedIn: true,
|
||||
username: payload.username,
|
||||
email: payload.email
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to changes
|
||||
userState.select(state => state.isLoggedIn).subscribe(isLoggedIn => {
|
||||
console.log('Login status changed:', isLoggedIn);
|
||||
});
|
||||
|
||||
// Dispatch actions
|
||||
await loginAction.trigger({ username: 'john', email: 'john@example.com' });
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| 🎯 **Type-safe** | Full TypeScript support with intelligent type inference |
|
||||
| ⚡ **Performance optimized** | Async state hash detection prevents unnecessary re-renders |
|
||||
| 💾 **Persistent state** | Built-in IndexedDB support for state persistence |
|
||||
| 🔄 **Reactive** | Powered by RxJS for elegant async handling |
|
||||
| 🧩 **Modular** | Organize state into logical, reusable parts |
|
||||
| ✅ **Validated** | Built-in state validation with extensible validation logic |
|
||||
| 🎭 **Flexible init modes** | Choose how state parts are initialized |
|
||||
| 📦 **Zero config** | Works out of the box with sensible defaults |
|
||||
| 🛡️ **Race condition safe** | Concurrent state part creation is handled safely |
|
||||
| ⏱️ **Timeout support** | `waitUntilPresent` supports optional timeouts |
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `computed(sources, fn)` | Create computed observable from state parts |
|
||||
| `attachContextProvider(element, options)` | Bridge state to Context Protocol |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ interface ITestState {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Init mode tests
|
||||
// ============================
|
||||
|
||||
tap.test('should handle soft init mode (default)', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation
|
||||
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
@@ -23,22 +26,19 @@ tap.test('should handle soft init mode (default)', async () => {
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
// Second call should return existing
|
||||
const statePart2 = await state.getStatePart<ITestState>('initTest');
|
||||
expect(statePart1).toEqual(statePart2);
|
||||
expect(statePart1 === statePart2).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should handle mandatory init mode', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation should succeed
|
||||
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'mandatory');
|
||||
expect(statePart1).toBeInstanceOf(smartstate.StatePart);
|
||||
|
||||
// Second call with mandatory should fail
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await state.getStatePart<ITestState>('initTest', {
|
||||
@@ -55,20 +55,18 @@ tap.test('should handle mandatory init mode', async () => {
|
||||
tap.test('should handle force init mode', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation
|
||||
const statePart1 = await state.getStatePart<ITestState>('forceTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
expect(statePart1.getState()?.value).toEqual(1);
|
||||
|
||||
// Force should create new state part
|
||||
const statePart2 = await state.getStatePart<ITestState>('forceTest', {
|
||||
value: 2,
|
||||
nested: { data: 'forced' }
|
||||
}, 'force');
|
||||
expect(statePart2.getState()?.value).toEqual(2);
|
||||
expect(statePart1).not.toEqual(statePart2);
|
||||
expect(statePart1 === statePart2).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should handle missing initial state error', async () => {
|
||||
@@ -92,7 +90,6 @@ tap.test('should handle state validation', async () => {
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
// Setting null should fail validation
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
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 statePart = new smartstate.StatePart<TTestStateParts, ITestState>('initTest');
|
||||
|
||||
// Select should filter out undefined states
|
||||
const values: (ITestState | undefined)[] = [];
|
||||
statePart.select().subscribe(val => values.push(val));
|
||||
|
||||
// Initially undefined, should not emit
|
||||
expect(values).toHaveLength(0);
|
||||
|
||||
// After setting state, should emit
|
||||
await statePart.setState({
|
||||
value: 1,
|
||||
nested: { data: 'test' }
|
||||
@@ -135,23 +129,504 @@ tap.test('should not notify on duplicate state', async () => {
|
||||
});
|
||||
|
||||
let notificationCount = 0;
|
||||
// Use select() to get initial value + changes
|
||||
statePart.select().subscribe(() => notificationCount++);
|
||||
|
||||
// Should have received initial state
|
||||
expect(notificationCount).toEqual(1);
|
||||
|
||||
// Set same state multiple times
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
|
||||
// Should still be 1 (no new notifications for duplicate state)
|
||||
expect(notificationCount).toEqual(1);
|
||||
|
||||
// Change state should notify
|
||||
await statePart.setState({ value: 2, nested: { data: 'changed' } });
|
||||
expect(notificationCount).toEqual(2);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// 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();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartstate',
|
||||
version: '2.0.31',
|
||||
description: 'A package for handling and managing state in applications.'
|
||||
version: '2.1.0',
|
||||
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.statepart.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 { StatePart } from './smartstate.classes.statepart.js';
|
||||
import { computed } from './smartstate.classes.computed.js';
|
||||
|
||||
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();
|
||||
|
||||
// Batch support
|
||||
private batchDepth = 0;
|
||||
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) {
|
||||
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
|
||||
* 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,16 +84,13 @@ 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
|
||||
break;
|
||||
case 'soft':
|
||||
case 'persistent':
|
||||
default:
|
||||
// Return existing state part
|
||||
return existingStatePart as StatePart<StatePartNameType, PayloadType>;
|
||||
}
|
||||
} else {
|
||||
// State part doesn't exist
|
||||
if (!initialArg) {
|
||||
throw new Error(
|
||||
`State part '${statePartNameArg}' does not exist and no initial state provided`
|
||||
@@ -73,9 +111,6 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
|
||||
/**
|
||||
* Creates a statepart
|
||||
* @param statePartName
|
||||
* @param initialPayloadArg
|
||||
* @param initMode
|
||||
*/
|
||||
private async createStatePart<PayloadType>(
|
||||
statePartName: StatePartNameType,
|
||||
@@ -91,17 +126,16 @@ export class Smartstate<StatePartNameType extends string> {
|
||||
}
|
||||
: null
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { Observable, shareReplay, takeUntil } from 'rxjs';
|
||||
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> {
|
||||
public name: TStatePartName;
|
||||
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
|
||||
public stateStore: TStatePayload | undefined;
|
||||
public smartstateRef?: Smartstate<any>;
|
||||
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
|
||||
|
||||
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingBatchNotification = false;
|
||||
|
||||
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) {
|
||||
this.name = nameArg;
|
||||
|
||||
// Initialize WebStore if webStoreOptions are provided
|
||||
if (webStoreOptionsArg) {
|
||||
this.webStoreOptions = webStoreOptionsArg;
|
||||
}
|
||||
@@ -43,23 +76,43 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
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
|
||||
* @param newStateArg
|
||||
*/
|
||||
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
|
||||
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 +120,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;
|
||||
}
|
||||
|
||||
@@ -82,6 +132,14 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
if (!this.stateStore) {
|
||||
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) => {
|
||||
return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
|
||||
};
|
||||
@@ -99,10 +157,9 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
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);
|
||||
}
|
||||
@@ -116,29 +173,58 @@ 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>(
|
||||
selectorFn?: (state: TStatePayload) => T
|
||||
selectorFn?: (state: TStatePayload) => T,
|
||||
options?: { signal?: AbortSignal }
|
||||
): plugins.smartrx.rxjs.Observable<T> {
|
||||
const hasSignal = options?.signal != null;
|
||||
|
||||
// Check memoization cache (only for non-signal selects)
|
||||
if (!hasSignal) {
|
||||
if (!selectorFn) {
|
||||
selectorFn = (state: TStatePayload) => <T>(<any>state);
|
||||
if (this.defaultSelectObservable) {
|
||||
return this.defaultSelectObservable as unknown as plugins.smartrx.rxjs.Observable<T>;
|
||||
}
|
||||
const mapped = this.state.pipe(
|
||||
} else if (this.selectorCache.has(selectorFn)) {
|
||||
return this.selectorCache.get(selectorFn)!;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
|
||||
|
||||
let mapped = this.state.pipe(
|
||||
plugins.smartrx.rxjs.ops.startWith(this.getState()),
|
||||
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;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* creates an action capable of modifying the state
|
||||
*/
|
||||
@@ -159,18 +245,32 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
}
|
||||
|
||||
/**
|
||||
* waits until a certain part of the state becomes available
|
||||
* @param selectorFn
|
||||
* @param timeoutMs - optional timeout in milliseconds to prevent indefinite waiting
|
||||
* 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) {
|
||||
resolved = true;
|
||||
@@ -189,12 +289,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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