@push.rocks/smartstate 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @push.rocks/smartstate
2
2
 
3
- A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
3
+ A TypeScript-first reactive state management library with processes, middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
4
4
 
5
5
  ## Issue Reporting and Security
6
6
 
@@ -48,7 +48,7 @@ await userState.setState({ name: 'Alice', loggedIn: true });
48
48
 
49
49
  ### 🧩 State Parts & Init Modes
50
50
 
51
- State parts are isolated, typed units of state. They are the building blocks of your application's state tree. Create them via `getStatePart()`:
51
+ State parts are isolated, typed units of state the building blocks of your application's state tree. Create them via `getStatePart()`:
52
52
 
53
53
  ```typescript
54
54
  const part = await state.getStatePart<IMyState>(name, initialState, initMode);
@@ -58,10 +58,10 @@ const part = await state.getStatePart<IMyState>(name, initialState, initMode);
58
58
  |-----------|----------|
59
59
  | `'soft'` (default) | Returns existing if found, creates new otherwise |
60
60
  | `'mandatory'` | Throws if state part already exists — useful for ensuring single-initialization |
61
- | `'force'` | Always creates a new state part, overwriting any existing one |
61
+ | `'force'` | Always creates a new state part, disposing and overwriting any existing one |
62
62
  | `'persistent'` | Like `'soft'` but automatically persists state to IndexedDB via WebStore |
63
63
 
64
- You can use either enums or string literal types for state part names:
64
+ You can use either string literal union types or enums for state part names:
65
65
 
66
66
  ```typescript
67
67
  // String literal types (simpler)
@@ -82,12 +82,12 @@ const settings = await state.getStatePart('settings', { theme: 'dark', fontSize:
82
82
 
83
83
  // ✅ Automatically saved to IndexedDB on every setState()
84
84
  // ✅ On next app load, persisted values override defaults
85
- // ✅ Persistence writes complete before in-memory updates (atomic)
85
+ // ✅ Persistence writes complete before in-memory updates
86
86
  ```
87
87
 
88
88
  ### 🔭 Selecting State
89
89
 
90
- `select()` returns an RxJS Observable that emits the current value immediately and on every subsequent change:
90
+ `select()` returns an RxJS Observable that emits the current value immediately (via `BehaviorSubject`) and on every subsequent change:
91
91
 
92
92
  ```typescript
93
93
  // Full state
@@ -99,6 +99,8 @@ userState.select((s) => s.name).subscribe((name) => console.log(name));
99
99
 
100
100
  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.
101
101
 
102
+ **Change detection** is built in: `select()` uses `distinctUntilChanged` with deep JSON comparison, so subscribers only fire when the selected value actually changes. Selecting `s => s.name` won't re-emit when only `s.count` changes.
103
+
102
104
  #### ✂️ AbortSignal Support
103
105
 
104
106
  Clean up subscriptions without manual `.unsubscribe()` — the modern way:
@@ -125,7 +127,6 @@ interface ILoginPayload {
125
127
  }
126
128
 
127
129
  const loginAction = userState.createAction<ILoginPayload>(async (statePart, payload) => {
128
- // You have access to the current state via statePart.getState()
129
130
  const current = statePart.getState();
130
131
  return { ...current, name: payload.username, loggedIn: true };
131
132
  });
@@ -136,7 +137,157 @@ await loginAction.trigger({ username: 'Alice', email: 'alice@example.com' });
136
137
  await userState.dispatchAction(loginAction, { username: 'Alice', email: 'alice@example.com' });
137
138
  ```
138
139
 
139
- Both `trigger()` and `dispatchAction()` return a Promise with the new state.
140
+ Both `trigger()` and `dispatchAction()` return a Promise with the new state. All dispatches are serialized through a mutation queue, so concurrent dispatches never cause lost updates.
141
+
142
+ #### 🔗 Nested Actions (Action Context)
143
+
144
+ When you need to dispatch sub-actions from within an action, use the `context` parameter. This is critical because calling `dispatchAction()` directly from inside an action would deadlock (it tries to acquire the mutation queue that's already held). The context's `dispatch()` bypasses the queue and executes inline:
145
+
146
+ ```typescript
147
+ const incrementAction = userState.createAction<number>(async (statePart, amount) => {
148
+ const current = statePart.getState();
149
+ return { ...current, count: current.count + amount };
150
+ });
151
+
152
+ const doubleIncrementAction = userState.createAction<number>(async (statePart, amount, context) => {
153
+ // ✅ Safe: uses context.dispatch() which bypasses the mutation queue
154
+ await context.dispatch(incrementAction, amount);
155
+ const current = statePart.getState();
156
+ return { ...current, count: current.count + amount };
157
+ });
158
+
159
+ // ❌ DON'T do this inside an action — it will deadlock:
160
+ // await statePart.dispatchAction(someAction, payload);
161
+ ```
162
+
163
+ A built-in depth limit (10 levels) prevents infinite circular dispatch chains, throwing a clear error if exceeded.
164
+
165
+ ### 🔄 Processes (Polling, Streams & Scheduled Tasks)
166
+
167
+ Processes are managed, pausable observable-to-state bridges — the "side effects" layer. They tie an ongoing data source (polling, WebSockets, event streams) to state updates with full lifecycle control and optional auto-pause.
168
+
169
+ #### Basic Process: Polling an API
170
+
171
+ ```typescript
172
+ import { interval, switchMap, from } from 'rxjs';
173
+
174
+ const metricsPoller = dashboard.createProcess<{ cpu: number; memory: number }>({
175
+ // Producer: an Observable factory — called on start and each resume
176
+ producer: () => interval(5000).pipe(
177
+ switchMap(() => from(fetch('/api/metrics').then(r => r.json()))),
178
+ ),
179
+ // Reducer: folds each produced value into state (runs through middleware & validation)
180
+ reducer: (currentState, metrics) => ({
181
+ ...currentState,
182
+ metrics,
183
+ lastUpdated: Date.now(),
184
+ }),
185
+ autoPause: 'visibility', // ⏸️ Stop polling when the tab is hidden
186
+ autoStart: true, // ▶️ Start immediately
187
+ });
188
+
189
+ // Full lifecycle control
190
+ metricsPoller.pause(); // Unsubscribes from producer
191
+ metricsPoller.resume(); // Re-subscribes (fresh subscription)
192
+ metricsPoller.dispose(); // Permanent cleanup
193
+
194
+ // Observe status reactively
195
+ metricsPoller.status; // 'idle' | 'running' | 'paused' | 'disposed'
196
+ metricsPoller.status$.subscribe(s => console.log('Process:', s));
197
+ ```
198
+
199
+ #### Scheduled Actions
200
+
201
+ Dispatch an existing action on a recurring interval — syntactic sugar over `createProcess`:
202
+
203
+ ```typescript
204
+ const refreshAction = dashboard.createAction<void>(async (sp) => {
205
+ const data = await fetch('/api/dashboard').then(r => r.json());
206
+ return { ...sp.getState()!, ...data, lastUpdated: Date.now() };
207
+ });
208
+
209
+ // Dispatches refreshAction every 30 seconds, auto-pauses when tab is hidden
210
+ const scheduled = dashboard.createScheduledAction({
211
+ action: refreshAction,
212
+ payload: undefined,
213
+ intervalMs: 30000,
214
+ autoPause: 'visibility',
215
+ });
216
+
217
+ // It's a full StateProcess — pause, resume, dispose all work
218
+ scheduled.dispose();
219
+ ```
220
+
221
+ #### Custom Auto-Pause Signals
222
+
223
+ Pass any `Observable<boolean>` as the auto-pause signal — `true` means active, `false` means pause:
224
+
225
+ ```typescript
226
+ import { fromEvent, map, startWith } from 'rxjs';
227
+
228
+ // Pause when offline, resume when online
229
+ const onlineSignal = fromEvent(window, 'online').pipe(
230
+ startWith(null),
231
+ map(() => navigator.onLine),
232
+ );
233
+
234
+ const syncProcess = userPart.createProcess<SyncPayload>({
235
+ producer: () => interval(10000).pipe(
236
+ switchMap(() => from(syncWithServer())),
237
+ ),
238
+ reducer: (state, result) => ({ ...state, ...result }),
239
+ autoPause: onlineSignal,
240
+ });
241
+ syncProcess.start();
242
+ ```
243
+
244
+ #### WebSocket / Live Streams
245
+
246
+ Pause disconnects; resume creates a fresh connection:
247
+
248
+ ```typescript
249
+ const liveProcess = tickerPart.createProcess<TradeEvent>({
250
+ producer: () => new Observable<TradeEvent>(subscriber => {
251
+ const ws = new WebSocket('wss://trades.example.com');
252
+ ws.onmessage = (e) => subscriber.next(JSON.parse(e.data));
253
+ ws.onerror = (e) => subscriber.error(e);
254
+ ws.onclose = () => subscriber.complete();
255
+ return () => ws.close(); // Teardown: close WebSocket on unsubscribe
256
+ }),
257
+ reducer: (state, trade) => ({
258
+ ...state,
259
+ lastPrice: trade.price,
260
+ trades: [...state.trades.slice(-99), trade],
261
+ }),
262
+ autoPause: 'visibility',
263
+ });
264
+ liveProcess.start();
265
+ ```
266
+
267
+ #### Error Recovery
268
+
269
+ If a producer errors, the process gracefully transitions to `'paused'` instead of dying. Call `resume()` to retry with a fresh subscription:
270
+
271
+ ```typescript
272
+ process.start();
273
+ // Producer errors → status becomes 'paused'
274
+ process.resume(); // Creates a fresh subscription — retry
275
+ ```
276
+
277
+ #### Process Cleanup Cascades
278
+
279
+ Disposing a `StatePart` or `Smartstate` instance automatically disposes all attached processes:
280
+
281
+ ```typescript
282
+ const p1 = part.createProcess({ ... });
283
+ const p2 = part.createProcess({ ... });
284
+ p1.start();
285
+ p2.start();
286
+
287
+ part.dispose();
288
+ console.log(p1.status); // 'disposed'
289
+ console.log(p2.status); // 'disposed'
290
+ ```
140
291
 
141
292
  ### 🛡️ Middleware
142
293
 
@@ -171,7 +322,7 @@ const remove = userState.addMiddleware(myMiddleware);
171
322
  remove(); // middleware no longer runs
172
323
  ```
173
324
 
174
- Middleware runs **sequentially** in insertion order. If any middleware throws, the state remains unchanged — the operation is **atomic**.
325
+ Middleware runs **sequentially** in insertion order. If any middleware throws, the state remains unchanged — the operation is **atomic**. Process-driven state updates go through middleware too.
175
326
 
176
327
  ### 🧮 Computed / Derived State
177
328
 
@@ -199,7 +350,7 @@ const greeting2$ = state.computed(
199
350
  );
200
351
  ```
201
352
 
202
- Computed observables are **lazy** — they only subscribe to their sources when someone subscribes to them, and they automatically unsubscribe when all subscribers disconnect.
353
+ Computed observables are **lazy** — they only subscribe to their sources when someone subscribes to them, and they automatically unsubscribe when all subscribers disconnect. They also use `distinctUntilChanged` to avoid redundant emissions when the derived value hasn't actually changed.
203
354
 
204
355
  ### 📦 Batch Updates
205
356
 
@@ -322,15 +473,31 @@ await userState.stateSetup(async (statePart) => {
322
473
  // Any dispatchAction() calls will automatically wait for stateSetup() to finish
323
474
  ```
324
475
 
476
+ ### 🧹 Disposal & Cleanup
477
+
478
+ Both `Smartstate` and individual `StatePart` instances support disposal for proper cleanup:
479
+
480
+ ```typescript
481
+ // Dispose a single state part — completes the BehaviorSubject, clears middleware, caches,
482
+ // and disposes all attached processes
483
+ userState.dispose();
484
+
485
+ // Dispose the entire Smartstate instance — disposes all state parts and clears internal maps
486
+ state.dispose();
487
+ ```
488
+
489
+ After disposal, `setState()` and `dispatchAction()` will throw if called on a disposed `StatePart`. Calling `start()`, `pause()`, or `resume()` on a disposed `StateProcess` also throws.
490
+
325
491
  ### 🏎️ Performance
326
492
 
327
493
  Smartstate is built with performance in mind:
328
494
 
329
495
  - **🔒 SHA256 Change Detection** — Uses content hashing to detect actual changes. Identical state values don't trigger notifications, even with different object references.
496
+ - **🎯 distinctUntilChanged on Selectors** — Sub-selectors only fire when the selected slice actually changes. `select(s => s.name)` won't emit when `s.count` changes.
330
497
  - **♻️ Selector Memoization** — `select(fn)` caches observables by function reference and shares them via `shareReplay({ refCount: true })`. Multiple subscribers share one upstream subscription.
331
498
  - **📦 Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification at the end of the call stack.
332
- - **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation or race conditions.
333
- - **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency even if the process crashes mid-write.
499
+ - **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation. All `setState()` and `dispatchAction()` calls are serialized through a mutation queue. Process values are serialized through their own internal queue.
500
+ - **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency.
334
501
  - **⏸️ Batch Deferred Notifications** — `batch()` suppresses all subscriber notifications until every update in the batch completes.
335
502
 
336
503
  ## API Reference
@@ -342,23 +509,26 @@ Smartstate is built with performance in mind:
342
509
  | `getStatePart(name, initial?, initMode?)` | Get or create a typed state part |
343
510
  | `batch(fn)` | Batch state updates, defer all notifications until complete |
344
511
  | `computed(sources, fn)` | Create a computed observable from multiple state parts |
512
+ | `dispose()` | Dispose all state parts and clear internal state |
345
513
  | `isBatching` | `boolean` — whether a batch is currently active |
346
- | `statePartMap` | Registry of all created state parts |
347
514
 
348
515
  ### `StatePart<TName, TPayload>`
349
516
 
350
517
  | Method | Description |
351
518
  |--------|-------------|
352
- | `getState()` | Get current state (returns `TPayload \| undefined`) |
519
+ | `getState()` | Get current state synchronously (`TPayload \| undefined`) |
353
520
  | `setState(newState)` | Set state — runs middleware → validates → persists → notifies |
354
- | `select(selectorFn?, options?)` | Returns an Observable of state or derived values. Options: `{ signal?: AbortSignal }` |
521
+ | `select(selectorFn?, options?)` | Observable of state or derived values. Options: `{ signal?: AbortSignal }` |
355
522
  | `createAction(actionDef)` | Create a reusable, typed state action |
356
523
  | `dispatchAction(action, payload)` | Dispatch an action and return the new state |
357
524
  | `addMiddleware(fn)` | Add a middleware interceptor. Returns a removal function |
358
525
  | `waitUntilPresent(selectorFn?, opts?)` | Wait for a state condition. Opts: `number` (timeout) or `{ timeoutMs?, signal? }` |
526
+ | `createProcess(options)` | Create a managed, pausable process tied to this state part |
527
+ | `createScheduledAction(options)` | Create a process that dispatches an action on a recurring interval |
359
528
  | `notifyChange()` | Manually trigger a change notification (with hash dedup) |
360
529
  | `notifyChangeCumulative()` | Debounced notification — fires at end of call stack |
361
530
  | `stateSetup(fn)` | Async state initialization with action serialization |
531
+ | `dispose()` | Complete the BehaviorSubject, dispose processes, clear middleware and caches |
362
532
 
363
533
  ### `StateAction<TState, TPayload>`
364
534
 
@@ -366,6 +536,23 @@ Smartstate is built with performance in mind:
366
536
  |--------|-------------|
367
537
  | `trigger(payload)` | Dispatch the action on its associated state part |
368
538
 
539
+ ### `StateProcess<TName, TPayload, TProducerValue>`
540
+
541
+ | Method / Property | Description |
542
+ |-------------------|-------------|
543
+ | `start()` | Start the process (subscribes to producer, sets up auto-pause) |
544
+ | `pause()` | Pause the process (unsubscribes from producer) |
545
+ | `resume()` | Resume a paused process (fresh subscription to producer) |
546
+ | `dispose()` | Permanently stop the process and clean up |
547
+ | `status` | Current status: `'idle' \| 'running' \| 'paused' \| 'disposed'` |
548
+ | `status$` | Observable of status transitions |
549
+
550
+ ### `IActionContext<TState>`
551
+
552
+ | Method | Description |
553
+ |--------|-------------|
554
+ | `dispatch(action, payload)` | Dispatch a sub-action inline (bypasses mutation queue). Available as the third argument to action definitions |
555
+
369
556
  ### Standalone Functions
370
557
 
371
558
  | Function | Description |
@@ -379,8 +566,13 @@ Smartstate is built with performance in mind:
379
566
  |------|-------------|
380
567
  | `TInitMode` | `'soft' \| 'mandatory' \| 'force' \| 'persistent'` |
381
568
  | `TMiddleware<TPayload>` | `(newState, oldState) => TPayload \| Promise<TPayload>` |
382
- | `IActionDef<TState, TPayload>` | Action definition function signature |
569
+ | `IActionDef<TState, TPayload>` | Action definition function signature (receives statePart, payload, context?) |
570
+ | `IActionContext<TState>` | Context for safe nested dispatch within actions |
383
571
  | `IContextProviderOptions<TPayload>` | Options for `attachContextProvider` |
572
+ | `IProcessOptions<TPayload, TValue>` | Options for `createProcess` (producer, reducer, autoPause, autoStart) |
573
+ | `IScheduledActionOptions<TPayload, TActionPayload>` | Options for `createScheduledAction` (action, payload, intervalMs, autoPause) |
574
+ | `TProcessStatus` | `'idle' \| 'running' \| 'paused' \| 'disposed'` |
575
+ | `TAutoPause` | `'visibility' \| Observable<boolean> \| false` |
384
576
 
385
577
  ## License and Legal Information
386
578
 
@@ -396,7 +588,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
396
588
 
397
589
  ### Company Information
398
590
 
399
- Task Venture Capital GmbH
591
+ Task Venture Capital GmbH
400
592
  Registered at District Court Bremen HRB 35230 HB, Germany
401
593
 
402
594
  For any legal inquiries or further information, please contact us via email at hello@task.vc.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartstate',
6
- version: '2.2.1',
6
+ version: '2.3.0',
7
7
  description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
8
8
  }
package/ts/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from './smartstate.classes.statepart.js';
3
3
  export * from './smartstate.classes.stateaction.js';
4
4
  export * from './smartstate.classes.computed.js';
5
5
  export * from './smartstate.contextprovider.js';
6
+ export * from './smartstate.classes.stateprocess.js';
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './smartstate.plugins.js';
2
- import { combineLatest, map } from 'rxjs';
2
+ import { combineLatest, map, distinctUntilChanged } from 'rxjs';
3
3
  import type { StatePart } from './smartstate.classes.statepart.js';
4
4
 
5
5
  /**
@@ -12,5 +12,6 @@ export function computed<TResult>(
12
12
  ): plugins.smartrx.rxjs.Observable<TResult> {
13
13
  return combineLatest(sources.map((sp) => sp.select())).pipe(
14
14
  map((states) => computeFn(...states)),
15
+ distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
15
16
  ) as plugins.smartrx.rxjs.Observable<TResult>;
16
17
  }
@@ -49,7 +49,11 @@ export class Smartstate<StatePartNameType extends string> {
49
49
  const pending = [...this.pendingNotifications];
50
50
  this.pendingNotifications.clear();
51
51
  for (const sp of pending) {
52
- await sp.notifyChange();
52
+ try {
53
+ await sp.notifyChange();
54
+ } catch (err) {
55
+ console.error(`Error flushing notification for state part:`, err);
56
+ }
53
57
  }
54
58
  }
55
59
  } finally {
@@ -69,6 +73,21 @@ export class Smartstate<StatePartNameType extends string> {
69
73
  return computed(sources, computeFn);
70
74
  }
71
75
 
76
+ /**
77
+ * disposes all state parts and clears internal state
78
+ */
79
+ public dispose(): void {
80
+ for (const key of Object.keys(this.statePartMap)) {
81
+ const part = this.statePartMap[key as StatePartNameType];
82
+ if (part) {
83
+ part.dispose();
84
+ }
85
+ }
86
+ this.statePartMap = {} as any;
87
+ this.pendingStatePartCreation.clear();
88
+ this.pendingNotifications.clear();
89
+ }
90
+
72
91
  /**
73
92
  * Allows getting and initializing a new statepart
74
93
  */
@@ -107,7 +126,7 @@ export class Smartstate<StatePartNameType extends string> {
107
126
  }
108
127
  }
109
128
 
110
- const creationPromise = this.createStatePart<PayloadType>(statePartNameArg, initialArg, initMode);
129
+ const creationPromise = this.createStatePart<PayloadType>(statePartNameArg, initialArg!, initMode);
111
130
  this.pendingStatePartCreation.set(statePartNameArg, creationPromise);
112
131
 
113
132
  try {
@@ -133,7 +152,7 @@ export class Smartstate<StatePartNameType extends string> {
133
152
  dbName: 'smartstate',
134
153
  storeName: statePartName,
135
154
  }
136
- : null
155
+ : undefined
137
156
  );
138
157
  newState.smartstateRef = this;
139
158
  await newState.init();
@@ -18,8 +18,8 @@ export interface IActionDef<TStateType, TActionPayloadType> {
18
18
  */
19
19
  export class StateAction<TStateType, TActionPayloadType> {
20
20
  constructor(
21
- public statePartRef: StatePart<any, any>,
22
- public actionDef: IActionDef<TStateType, TActionPayloadType>
21
+ public readonly statePartRef: StatePart<any, any>,
22
+ public readonly actionDef: IActionDef<TStateType, TActionPayloadType>
23
23
  ) {}
24
24
 
25
25
  public trigger(payload: TActionPayloadType): Promise<TStateType> {
@@ -1,7 +1,8 @@
1
1
  import * as plugins from './smartstate.plugins.js';
2
- import { Observable, shareReplay, takeUntil } from 'rxjs';
2
+ import { BehaviorSubject, Observable, shareReplay, takeUntil, distinctUntilChanged, interval } from 'rxjs';
3
3
  import { StateAction, type IActionDef, type IActionContext } from './smartstate.classes.stateaction.js';
4
4
  import type { Smartstate } from './smartstate.classes.smartstate.js';
5
+ import { StateProcess, type IProcessOptions, type IScheduledActionOptions } from './smartstate.classes.stateprocess.js';
5
6
 
6
7
  export type TMiddleware<TPayload> = (
7
8
  newState: TPayload,
@@ -31,19 +32,23 @@ export class StatePart<TStatePartName, TStatePayload> {
31
32
  private static readonly MAX_NESTED_DISPATCH_DEPTH = 10;
32
33
 
33
34
  public name: TStatePartName;
34
- public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
35
- public stateStore: TStatePayload | undefined;
35
+ private state = new BehaviorSubject<TStatePayload | undefined>(undefined);
36
+ private stateStore: TStatePayload | undefined;
36
37
  public smartstateRef?: Smartstate<any>;
38
+ private disposed = false;
37
39
  private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
38
40
 
39
41
  private mutationQueue: Promise<any> = Promise.resolve();
40
42
  private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
41
43
 
42
- private webStoreOptions: plugins.webstore.IWebStoreOptions;
44
+ private webStoreOptions: plugins.webstore.IWebStoreOptions | undefined;
43
45
  private webStore: plugins.webstore.WebStore<TStatePayload> | null = null;
44
46
 
45
47
  private middlewares: TMiddleware<TStatePayload>[] = [];
46
48
 
49
+ // Process tracking
50
+ private processes: StateProcess<TStatePartName, TStatePayload, any>[] = [];
51
+
47
52
  // Selector memoization
48
53
  private selectorCache = new WeakMap<Function, plugins.smartrx.rxjs.Observable<any>>();
49
54
  private defaultSelectObservable: plugins.smartrx.rxjs.Observable<TStatePayload> | null = null;
@@ -97,6 +102,9 @@ export class StatePart<TStatePartName, TStatePayload> {
97
102
  * sets the stateStore to the new state (serialized via mutation queue)
98
103
  */
99
104
  public async setState(newStateArg: TStatePayload): Promise<TStatePayload> {
105
+ if (this.disposed) {
106
+ throw new Error(`StatePart '${this.name}' has been disposed`);
107
+ }
100
108
  return this.mutationQueue = this.mutationQueue.then(
101
109
  () => this.applyState(newStateArg),
102
110
  () => this.applyState(newStateArg),
@@ -212,22 +220,24 @@ export class StatePart<TStatePartName, TStatePayload> {
212
220
  }
213
221
 
214
222
  const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
223
+ const SELECTOR_ERROR: unique symbol = Symbol('selector-error');
215
224
 
216
225
  let mapped = this.state.pipe(
217
- plugins.smartrx.rxjs.ops.startWith(this.getState()),
218
226
  plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined),
219
227
  plugins.smartrx.rxjs.ops.map((stateArg) => {
220
228
  try {
221
229
  return effectiveSelectorFn(stateArg);
222
230
  } catch (e) {
223
231
  console.error(`Selector error in state part '${this.name}':`, e);
224
- return undefined;
232
+ return SELECTOR_ERROR as any;
225
233
  }
226
- })
234
+ }),
235
+ plugins.smartrx.rxjs.ops.filter((v: any) => v !== SELECTOR_ERROR),
236
+ distinctUntilChanged((a: any, b: any) => JSON.stringify(a) === JSON.stringify(b)),
227
237
  );
228
238
 
229
239
  if (hasSignal) {
230
- mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal)));
240
+ mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal!)));
231
241
  return mapped;
232
242
  }
233
243
 
@@ -277,19 +287,16 @@ export class StatePart<TStatePartName, TStatePayload> {
277
287
  * dispatches an action on the statepart level
278
288
  */
279
289
  public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
290
+ if (this.disposed) {
291
+ throw new Error(`StatePart '${this.name}' has been disposed`);
292
+ }
280
293
  await this.cumulativeDeferred.promise;
281
- return this.mutationQueue = this.mutationQueue.then(
282
- async () => {
283
- const context = this.createActionContext(0);
284
- const newState = await stateAction.actionDef(this, actionPayload, context);
285
- return this.applyState(newState);
286
- },
287
- async () => {
288
- const context = this.createActionContext(0);
289
- const newState = await stateAction.actionDef(this, actionPayload, context);
290
- return this.applyState(newState);
291
- },
292
- );
294
+ const execute = async () => {
295
+ const context = this.createActionContext(0);
296
+ const newState = await stateAction.actionDef(this, actionPayload, context);
297
+ return this.applyState(newState);
298
+ };
299
+ return this.mutationQueue = this.mutationQueue.then(execute, execute);
293
300
  }
294
301
 
295
302
  /**
@@ -374,11 +381,68 @@ export class StatePart<TStatePartName, TStatePayload> {
374
381
  await this.setState(await resultPromise);
375
382
  }
376
383
 
384
+ /**
385
+ * creates a managed, pausable process that ties an observable producer to state updates
386
+ */
387
+ public createProcess<TProducerValue>(
388
+ options: IProcessOptions<TStatePayload, TProducerValue>
389
+ ): StateProcess<TStatePartName, TStatePayload, TProducerValue> {
390
+ if (this.disposed) {
391
+ throw new Error(`StatePart '${this.name}' has been disposed`);
392
+ }
393
+ const process = new StateProcess<TStatePartName, TStatePayload, TProducerValue>(this, options);
394
+ this.processes.push(process);
395
+ if (options.autoStart) {
396
+ process.start();
397
+ }
398
+ return process;
399
+ }
400
+
401
+ /**
402
+ * creates a process that dispatches an action on a recurring interval
403
+ */
404
+ public createScheduledAction<TActionPayload>(
405
+ options: IScheduledActionOptions<TStatePayload, TActionPayload>
406
+ ): StateProcess<TStatePartName, TStatePayload, number> {
407
+ if (this.disposed) {
408
+ throw new Error(`StatePart '${this.name}' has been disposed`);
409
+ }
410
+ const process = new StateProcess<TStatePartName, TStatePayload, number>(this, {
411
+ producer: () => interval(options.intervalMs),
412
+ sideEffect: async () => {
413
+ await options.action.trigger(options.payload);
414
+ },
415
+ autoPause: options.autoPause ?? false,
416
+ });
417
+ this.processes.push(process);
418
+ process.start();
419
+ return process;
420
+ }
421
+
422
+ /** @internal — called by StateProcess.dispose() to remove itself */
423
+ public _removeProcess(process: StateProcess<any, any, any>): void {
424
+ const idx = this.processes.indexOf(process);
425
+ if (idx !== -1) {
426
+ this.processes.splice(idx, 1);
427
+ }
428
+ }
429
+
377
430
  /**
378
431
  * disposes the state part, completing the Subject and cleaning up resources
379
432
  */
380
433
  public dispose(): void {
434
+ if (this.disposed) return;
435
+ this.disposed = true;
436
+
437
+ // Dispose all processes first
438
+ for (const process of [...this.processes]) {
439
+ process.dispose();
440
+ }
441
+ this.processes.length = 0;
442
+
381
443
  this.state.complete();
444
+ this.mutationQueue = Promise.resolve() as any;
445
+
382
446
  if (this.pendingCumulativeNotification) {
383
447
  clearTimeout(this.pendingCumulativeNotification);
384
448
  this.pendingCumulativeNotification = null;
@@ -388,5 +452,6 @@ export class StatePart<TStatePartName, TStatePayload> {
388
452
  this.defaultSelectObservable = null;
389
453
  this.webStore = null;
390
454
  this.smartstateRef = undefined;
455
+ this.stateStore = undefined;
391
456
  }
392
457
  }