fix(core): serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling

This commit is contained in:
2026-02-28 08:52:41 +00:00
parent 2f0b39ae41
commit 9312b8908c
8 changed files with 383 additions and 120 deletions

248
readme.md
View File

@@ -46,9 +46,9 @@ userState.select((s) => s.name).subscribe((name) => {
await userState.setState({ name: 'Alice', loggedIn: true });
```
### State Parts & Init Modes
### 🧩 State Parts & Init Modes
State parts are isolated, typed units of state. Create them with `getStatePart()`:
State parts are isolated, typed units of state. They are the building blocks of your application's state tree. Create them via `getStatePart()`:
```typescript
const part = await state.getStatePart<IMyState>(name, initialState, initMode);
@@ -57,68 +57,95 @@ const part = await state.getStatePart<IMyState>(name, initialState, initMode);
| 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 |
| `'mandatory'` | Throws if state part already exists — useful for ensuring single-initialization |
| `'force'` | Always creates a new state part, overwriting any existing one |
| `'persistent'` | Like `'soft'` but automatically persists state to IndexedDB via WebStore |
#### Persistent State
You can use either enums or string literal types for state part names:
```typescript
const settings = await state.getStatePart('settings', { theme: 'dark' }, 'persistent');
// Automatically saved to IndexedDB. On next app load, persisted values override defaults.
// String literal types (simpler)
type AppParts = 'user' | 'settings' | 'cart';
// Enums (more explicit)
enum AppParts {
User = 'user',
Settings = 'settings',
Cart = 'cart',
}
```
### Selecting State
#### 💾 Persistent State
`select()` returns an RxJS Observable that emits the current value immediately and on every change:
```typescript
const settings = await state.getStatePart('settings', { theme: 'dark', fontSize: 14 }, 'persistent');
// ✅ Automatically saved to IndexedDB on every setState()
// ✅ On next app load, persisted values override defaults
// ✅ Persistence writes complete before in-memory updates (atomic)
```
### 🔭 Selecting State
`select()` returns an RxJS Observable that emits the current value immediately and on every subsequent change:
```typescript
// Full state
userState.select().subscribe((state) => console.log(state));
// Derived value via selector
// Derived value via selector function
userState.select((s) => s.name).subscribe((name) => console.log(name));
```
Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers.
Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers via `shareReplay`. This means you can call `select(mySelector)` in multiple places without creating duplicate subscriptions.
#### AbortSignal Support
#### ✂️ AbortSignal Support
Clean up subscriptions without manual `unsubscribe()`:
Clean up subscriptions without manual `.unsubscribe()` — the modern way:
```typescript
const controller = new AbortController();
userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => {
console.log(name); // stops receiving when aborted
console.log(name); // automatically stops receiving when aborted
});
// Later: clean up
// Later: clean up all subscriptions tied to this signal
controller.abort();
```
### Actions
### Actions
Actions provide controlled, named state mutations:
Actions provide controlled, named state mutations with full async support:
```typescript
const login = userState.createAction<{ name: string }>(async (statePart, payload) => {
return { ...statePart.getState(), name: payload.name, loggedIn: true };
interface ILoginPayload {
username: string;
email: string;
}
const loginAction = userState.createAction<ILoginPayload>(async (statePart, payload) => {
// You have access to the current state via statePart.getState()
const current = statePart.getState();
return { ...current, name: payload.username, loggedIn: true };
});
// Two equivalent ways to dispatch:
await login.trigger({ name: 'Alice' });
await userState.dispatchAction(login, { name: 'Alice' });
await loginAction.trigger({ username: 'Alice', email: 'alice@example.com' });
// or
await userState.dispatchAction(loginAction, { username: 'Alice', email: 'alice@example.com' });
```
### Middleware
Both `trigger()` and `dispatchAction()` return a Promise with the new state.
Intercept every `setState()` call to transform, validate, or reject state changes:
### 🛡️ Middleware
Intercept every `setState()` call to transform, validate, log, or reject state changes:
```typescript
// Logging middleware
userState.addMiddleware((newState, oldState) => {
console.log('State changing from', oldState, 'to', newState);
console.log('State changing:', oldState, '', newState);
return newState;
});
@@ -133,16 +160,22 @@ userState.addMiddleware((newState) => {
return { ...newState, name: newState.name.trim() };
});
// Removal — addMiddleware returns a dispose function
// Async middleware
userState.addMiddleware(async (newState, oldState) => {
await auditLog('state-change', { from: oldState, to: newState });
return newState;
});
// Removal — addMiddleware() returns a dispose function
const remove = userState.addMiddleware(myMiddleware);
remove(); // middleware no longer runs
```
Middleware runs sequentially in insertion order. If any middleware throws, the state is unchanged (atomic).
Middleware runs **sequentially** in insertion order. If any middleware throws, the state remains unchanged — the operation is **atomic**.
### Computed / Derived State
### 🧮 Computed / Derived State
Derive reactive values from one or more state parts:
Derive reactive values from one or more state parts using `combineLatest` under the hood:
```typescript
import { computed } from '@push.rocks/smartstate';
@@ -159,42 +192,44 @@ const greeting$ = computed(
greeting$.subscribe((msg) => console.log(msg));
// => "Hello, Jane (en)"
// Also available as a method on Smartstate:
const greeting2$ = state.computed([userState, settingsState], (user, settings) => /* ... */);
// Also available as a convenience method on the Smartstate instance:
const greeting2$ = state.computed(
[userState, settingsState],
(user, settings) => `${user.firstName} - ${settings.locale}`,
);
```
Computed observables are **lazy** — they only subscribe to sources when someone subscribes to them.
Computed observables are **lazy** — they only subscribe to their sources when someone subscribes to them, and they automatically unsubscribe when all subscribers disconnect.
### Batch Updates
### 📦 Batch Updates
Update multiple state parts without intermediate notifications:
Update multiple state parts at once while deferring all notifications until the entire batch completes:
```typescript
const partA = await state.getStatePart('a', { value: 1 });
const partB = await state.getStatePart('b', { value: 2 });
// 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
// No notifications fire inside the batch
});
// Both subscribers now fire with their new values
// Both subscribers now fire with their new values simultaneously
// Nested batches are supported — flush happens at the outermost level
// Nested batches are supported — flush happens at the outermost level only
await state.batch(async () => {
await partA.setState({ value: 100 });
await state.batch(async () => {
await partB.setState({ value: 200 });
});
// Still deferred
// Still deferred — inner batch doesn't trigger flush
});
// Now both fire
```
### Waiting for State
### Waiting for State
Wait for a specific state condition to be met:
Wait for a specific state condition to be met before proceeding:
```typescript
// Wait for any truthy state
@@ -203,10 +238,10 @@ const currentState = await userState.waitUntilPresent();
// Wait for a specific condition
const name = await userState.waitUntilPresent((s) => s.name || undefined);
// With timeout (backward compatible)
// With timeout (milliseconds)
const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
// With AbortSignal
// With AbortSignal and/or timeout via options object
const controller = new AbortController();
try {
const name = await userState.waitUntilPresent(
@@ -214,109 +249,142 @@ try {
{ timeoutMs: 5000, signal: controller.signal },
);
} catch (e) {
// 'Aborted' or timeout error
// e.message is 'Aborted' or 'waitUntilPresent timed out after 5000ms'
}
```
### Context Protocol Bridge (Web Components)
### 🌐 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):
Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md). This lets any web component framework (Lit, FAST, Stencil, or vanilla) consume your state without coupling:
```typescript
import { attachContextProvider } from '@push.rocks/smartstate';
// Define a context key
// Define a context key (use Symbol for uniqueness)
const themeContext = Symbol('theme');
// Attach a provider to a DOM element
const cleanup = attachContextProvider(myElement, {
// Attach a provider to a DOM element — any descendant can consume it
const cleanup = attachContextProvider(document.body, {
context: themeContext,
statePart: settingsState,
selectorFn: (s) => s.theme, // optional: provide derived value
selectorFn: (s) => s.theme, // optional: provide a derived value instead of full state
});
// Any descendant can request this context:
myElement.dispatchEvent(
// A consumer dispatches a context-request event:
myComponent.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
callback: (theme) => console.log('Got theme:', theme),
subscribe: true, // receive updates whenever the state changes
},
}),
);
// Cleanup when done
// Works seamlessly with Lit's @consume() decorator, FAST's context, etc.
// Cleanup when the provider is no longer needed
cleanup();
```
This works with Lit's `@consume()` decorator, FAST, or any framework implementing the Context Protocol.
### ✅ State Validation
### State Validation
Built-in null/undefined validation. Extend for custom rules:
Built-in validation prevents `null` and `undefined` from being set as state. For custom validation, extend `StatePart`:
```typescript
class ValidatedPart<T> extends StatePart<string, T> {
protected validateState(stateArg: any): stateArg is T {
return super.validateState(stateArg) && typeof stateArg.name === 'string';
import { StatePart } from '@push.rocks/smartstate';
class ValidatedUserPart extends StatePart<string, IUserState> {
protected validateState(stateArg: any): stateArg is IUserState {
return (
super.validateState(stateArg) &&
typeof stateArg.name === 'string' &&
typeof stateArg.loggedIn === 'boolean'
);
}
}
```
### Performance Features
If validation fails, `setState()` throws and the state remains unchanged.
- **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 Setup
Initialize state with async operations while ensuring actions wait for setup to complete:
```typescript
await userState.stateSetup(async (statePart) => {
const userData = await fetchUserFromAPI();
return { ...statePart.getState(), ...userData };
});
// Any dispatchAction() calls will automatically wait for stateSetup() to finish
```
### 🏎️ Performance
Smartstate is built with performance in mind:
- **🔒 SHA256 Change Detection** — Uses content hashing to detect actual changes. Identical state values don't trigger notifications, even with different object references.
- **♻️ Selector Memoization** — `select(fn)` caches observables by function reference and shares them via `shareReplay({ refCount: true })`. Multiple subscribers share one upstream subscription.
- **📦 Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification at the end of the call stack.
- **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation or race conditions.
- **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency even if the process crashes mid-write.
- **⏸️ Batch Deferred Notifications** — `batch()` suppresses all subscriber notifications until every update in the batch completes.
## API Reference
### `Smartstate<T>`
| 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 |
| Method / Property | Description |
|-------------------|-------------|
| `getStatePart(name, initial?, initMode?)` | Get or create a typed state part |
| `batch(fn)` | Batch state updates, defer all notifications until complete |
| `computed(sources, fn)` | Create a computed observable from multiple state parts |
| `isBatching` | `boolean` — whether a batch is currently active |
| `statePartMap` | Registry of all created state parts |
### `StatePart<TName, TPayload>`
| 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 |
| `getState()` | Get current state (returns `TPayload \| undefined`) |
| `setState(newState)` | Set state runs middleware validates persists notifies |
| `select(selectorFn?, options?)` | Returns an Observable of state or derived values. Options: `{ signal?: AbortSignal }` |
| `createAction(actionDef)` | Create a reusable, typed state action |
| `dispatchAction(action, payload)` | Dispatch an action and return the new state |
| `addMiddleware(fn)` | Add a middleware interceptor. Returns a removal function |
| `waitUntilPresent(selectorFn?, opts?)` | Wait for a state condition. Opts: `number` (timeout) or `{ timeoutMs?, signal? }` |
| `notifyChange()` | Manually trigger a change notification (with hash dedup) |
| `notifyChangeCumulative()` | Debounced notification — fires at end of call stack |
| `stateSetup(fn)` | Async state initialization with action serialization |
### `StateAction<TState, TPayload>`
| Method | Description |
|--------|-------------|
| `trigger(payload)` | Dispatch the action |
| `trigger(payload)` | Dispatch the action on its associated state part |
### Standalone Functions
| Function | Description |
|----------|-------------|
| `computed(sources, fn)` | Create computed observable from state parts |
| `attachContextProvider(element, options)` | Bridge state to Context Protocol |
| `computed(sources, fn)` | Create a computed observable from multiple state parts |
| `attachContextProvider(element, options)` | Bridge a state part to the W3C Context Protocol |
### Exported Types
| Type | Description |
|------|-------------|
| `TInitMode` | `'soft' \| 'mandatory' \| 'force' \| 'persistent'` |
| `TMiddleware<TPayload>` | `(newState, oldState) => TPayload \| Promise<TPayload>` |
| `IActionDef<TState, TPayload>` | Action definition function signature |
| `IContextProviderOptions<TPayload>` | Options for `attachContextProvider` |
## 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.
@@ -328,7 +396,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.