Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f0b39ae41 | |||
| 575477df09 | |||
| 39aa63bdb3 | |||
| c1aa4eae5e | |||
| d8decdb3e5 | |||
| 03cfee2003 |
31
changelog.md
31
changelog.md
@@ -1,5 +1,36 @@
|
||||
# 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
|
||||
|
||||
- Bump @git.zone/tsbundle from ^2.8.3 to ^2.9.0
|
||||
- Bump @types/node from ^25.2.0 to ^25.3.2
|
||||
- Update documented dependency set/version to v2.0.30 in readme.hints.md
|
||||
- Fix README license file path from LICENSE to license in readme.md
|
||||
|
||||
## 2026-02-02 - 2.0.30 - fix(config)
|
||||
update npmextra configuration and improve README: rename package keys, add release registry config, clarify waitUntilPresent timeout and notification/persistence behavior
|
||||
|
||||
- Renamed npmextra keys: 'gitzone' → '@git.zone/cli' and 'tsdoc' → '@git.zone/tsdoc'
|
||||
- Added release configuration for @git.zone/cli including registries (verdaccio and npm) and accessLevel
|
||||
- Removed top-level 'npmci' section
|
||||
- Added new '@ship.zone/szci' entry with npmGlobalTools
|
||||
- README: added waitUntilPresent timeout example with error handling
|
||||
- README: clarified notifyChangeCumulative is debounced and documented persistence behavior (merge with defaults, atomic writes)
|
||||
- README: documented concurrency/race-condition safety and timeout support for waitUntilPresent
|
||||
|
||||
## 2026-02-02 - 2.0.29 - fix(smartstate)
|
||||
prevent duplicate statepart creation and fix persistence/notification race conditions
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
{
|
||||
@@ -14,13 +10,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"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": [
|
||||
@@ -33,11 +29,27 @@
|
||||
"state selection",
|
||||
"state notification",
|
||||
"asynchronous state",
|
||||
"cumulative notification"
|
||||
"cumulative notification",
|
||||
"middleware",
|
||||
"computed state",
|
||||
"batch updates",
|
||||
"context protocol",
|
||||
"web components",
|
||||
"AbortSignal"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"tsdoc": {
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
17
package.json
17
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@push.rocks/smartstate",
|
||||
"version": "2.0.29",
|
||||
"version": "2.1.0",
|
||||
"private": false,
|
||||
"description": "A package for handling and managing state in applications.",
|
||||
"description": "A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
@@ -15,14 +15,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsbundle": "^2.9.0",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^25.2.0"
|
||||
"@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": {
|
||||
|
||||
9915
pnpm-lock.yaml
generated
Normal file
9915
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# Smartstate Implementation Notes
|
||||
|
||||
## Current API (as of v2.0.28+)
|
||||
## Current API (as of v2.0.31)
|
||||
|
||||
### State Part Initialization
|
||||
- State parts can be created with different init modes: 'soft' (default), 'mandatory', 'force', 'persistent'
|
||||
@@ -8,53 +8,70 @@
|
||||
- 'mandatory' - requires state part to not exist, fails if it does
|
||||
- 'force' - always creates new state part, overwriting any existing
|
||||
- 'persistent' - like 'soft' but with WebStore persistence (IndexedDB)
|
||||
- Persistent mode automatically calls init() internally - no need to call it manually
|
||||
- Persistent mode automatically calls init() internally
|
||||
- State merge order fixed: initial state takes precedence over stored state
|
||||
|
||||
### Actions
|
||||
- Actions are created with `createAction()` method
|
||||
- Two ways to dispatch actions:
|
||||
1. `stateAction.trigger(payload)` - returns Promise<TStatePayload>
|
||||
2. `await statePart.dispatchAction(stateAction, payload)` - returns Promise<TStatePayload>
|
||||
- Both methods return the same Promise, providing flexibility in usage
|
||||
- Two ways to dispatch: `stateAction.trigger(payload)` or `statePart.dispatchAction(stateAction, payload)`
|
||||
- Both return Promise<TStatePayload>
|
||||
|
||||
### State Management Methods
|
||||
- `select()` - returns Observable with startWith current state, filters undefined states
|
||||
- `waitUntilPresent()` - waits for specific state condition
|
||||
- `select(fn?, { signal? })` - returns Observable, memoized by selector fn ref, supports AbortSignal
|
||||
- `waitUntilPresent(fn?, number | { timeoutMs?, signal? })` - waits for state condition, backward compat with number arg
|
||||
- `stateSetup()` - async state initialization with cumulative defer
|
||||
- `notifyChangeCumulative()` - defers notification to end of call stack
|
||||
- `getState()` - returns current state or undefined
|
||||
- `setState()` - validates state before setting, notifies only on actual changes
|
||||
- `setState()` - runs middleware, validates, persists, notifies
|
||||
- `addMiddleware(fn)` - intercepts setState, returns removal function
|
||||
|
||||
### Middleware
|
||||
- Type: `(newState, oldState) => newState | Promise<newState>`
|
||||
- Runs sequentially in insertion order before validation/persistence
|
||||
- Throw to reject state changes (atomic — state unchanged on error)
|
||||
- Does NOT run during initial createStatePart() hydration
|
||||
|
||||
### Selector Memoization
|
||||
- Uses WeakMap<Function, Observable> for fn-keyed cache
|
||||
- `defaultSelectObservable` for no-arg select()
|
||||
- Wrapped in `shareReplay({ bufferSize: 1, refCount: true })`
|
||||
- NOT cached when AbortSignal is provided
|
||||
|
||||
### Batch Updates
|
||||
- `smartstate.batch(async () => {...})` — defers notifications until batch completes
|
||||
- Supports nesting — only flushes at outermost level
|
||||
- StatePart has `smartstateRef` set by `createStatePart()` for batch awareness
|
||||
- State parts created via `new StatePart()` directly work without batching
|
||||
|
||||
### Computed State
|
||||
- `computed(sources, fn)` — standalone function using `combineLatest` + `map`
|
||||
- Also available as `smartstate.computed(sources, fn)`
|
||||
- Lazy — only subscribes when subscribed to
|
||||
|
||||
### Context Protocol Bridge
|
||||
- `attachContextProvider(element, { context, statePart, selectorFn? })` — returns cleanup fn
|
||||
- Listens for `context-request` CustomEvent on element
|
||||
- Supports one-shot and subscription modes
|
||||
- Works with Lit @consume(), FAST, or any Context Protocol consumer
|
||||
|
||||
### State Hash Detection
|
||||
- Uses SHA256 hash to detect actual state changes
|
||||
- Fixed: Hash comparison now properly awaits async hash calculation
|
||||
- Hash comparison properly awaits async hash calculation
|
||||
- Prevents duplicate notifications for identical state values
|
||||
- `notifyChange()` is now async to support proper hash comparison
|
||||
|
||||
### State Validation
|
||||
- Basic validation ensures state is not null/undefined
|
||||
- `validateState()` method can be overridden in subclasses for custom validation
|
||||
- Validation runs on both setState() and when loading from persistent storage
|
||||
- `validateState()` can be overridden in subclasses
|
||||
|
||||
### Type System
|
||||
- Can use either enums or string literal types for state part names
|
||||
- Test uses simple string types: `type TMyStateParts = 'testStatePart'`
|
||||
- State can be undefined initially, handled properly in select() and other methods
|
||||
### Key Notes
|
||||
- `smartstateRef` creates circular ref between StatePart and Smartstate
|
||||
- Use `===` not deep equality for StatePart comparison in tests
|
||||
- Direct rxjs imports used for: Observable, shareReplay, takeUntil, combineLatest, map
|
||||
|
||||
## Recent Fixes (v2.0.24+)
|
||||
1. Fixed state hash bug - now properly compares hash values instead of promises
|
||||
2. Fixed state initialization merge order - initial state now takes precedence
|
||||
3. Ensured stateStore is properly typed as potentially undefined
|
||||
4. Simplified init mode logic with clear behavior for each mode
|
||||
5. Added state validation with extensible validateState() method
|
||||
6. Made notifyChange() async to support proper hash comparison
|
||||
7. Updated select() to filter undefined states
|
||||
|
||||
## Dependency Versions (v2.0.28)
|
||||
## Dependency Versions (v2.0.31)
|
||||
- @git.zone/tsbuild: ^4.1.2
|
||||
- @git.zone/tsbundle: ^2.8.3
|
||||
- @git.zone/tsbundle: ^2.9.0
|
||||
- @git.zone/tsrun: ^2.0.1
|
||||
- @git.zone/tstest: ^3.1.8
|
||||
- @push.rocks/smartjson: ^6.0.0
|
||||
- @types/node: ^25.2.0
|
||||
- @types/node: ^25.3.2
|
||||
|
||||
468
readme.md
468
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,299 +8,315 @@ 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();
|
||||
|
||||
// Wait for a specific condition
|
||||
const name = await userState.waitUntilPresent((s) => s.name || undefined);
|
||||
|
||||
// With timeout (backward compatible)
|
||||
const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
|
||||
|
||||
// With AbortSignal
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const name = await userState.waitUntilPresent(
|
||||
(s) => s.name || undefined,
|
||||
{ timeoutMs: 5000, signal: controller.signal },
|
||||
);
|
||||
} catch (e) {
|
||||
// 'Aborted' or timeout error
|
||||
}
|
||||
```
|
||||
|
||||
const loginUserAction = userStatePart.createAction<ILoginPayload>(async (statePart, payload) => {
|
||||
return { ...statePart.getState(), isLoggedIn: true, username: payload.username };
|
||||
### Context Protocol Bridge (Web Components)
|
||||
|
||||
Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md):
|
||||
|
||||
```typescript
|
||||
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
|
||||
});
|
||||
|
||||
// Dispatch the action to update the state
|
||||
const newState = await loginUserAction.trigger({ username: 'johnDoe' });
|
||||
```
|
||||
|
||||
### 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 a specific state condition
|
||||
await userStatePart.waitUntilPresent();
|
||||
|
||||
// Wait for a specific property to be present
|
||||
await userStatePart.waitUntilPresent(state => state.username);
|
||||
|
||||
// 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
|
||||
userStatePart.notifyChangeCumulative();
|
||||
```
|
||||
|
||||
### Persistent State with WebStore
|
||||
|
||||
`Smartstate` supports persistent states using WebStore (IndexedDB-based storage), allowing you to maintain state across sessions:
|
||||
|
||||
```typescript
|
||||
const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>(
|
||||
AppStateParts.SettingsState,
|
||||
{ theme: 'light' }, // Initial state
|
||||
'persistent' // Mode
|
||||
// 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
|
||||
- Manages storage with configurable database and store names
|
||||
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()`
|
||||
- **🎯 Selective Subscriptions**: Use selectors to subscribe only to specific state properties
|
||||
- **✨ Undefined State Filtering**: The `select()` method automatically filters out undefined states
|
||||
## 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 |
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `computed(sources, fn)` | Create computed observable from state parts |
|
||||
| `attachContextProvider(element, options)` | Bridge state to Context Protocol |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -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.29',
|
||||
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