@push.rocks/smartstate 2.2.1 → 2.3.1

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.
@@ -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
  }
@@ -0,0 +1,177 @@
1
+ import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
2
+ import type { StatePart } from './smartstate.classes.statepart.js';
3
+ import type { StateAction } from './smartstate.classes.stateaction.js';
4
+
5
+ export type TProcessStatus = 'idle' | 'running' | 'paused' | 'disposed';
6
+ export type TAutoPause = 'visibility' | Observable<boolean> | false;
7
+
8
+ export interface IProcessOptions<TStatePayload, TProducerValue> {
9
+ producer: () => Observable<TProducerValue>;
10
+ reducer: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
11
+ autoPause?: TAutoPause;
12
+ autoStart?: boolean;
13
+ }
14
+
15
+ export interface IScheduledActionOptions<TStatePayload, TActionPayload> {
16
+ action: StateAction<TStatePayload, TActionPayload>;
17
+ payload: TActionPayload;
18
+ intervalMs: number;
19
+ autoPause?: TAutoPause;
20
+ }
21
+
22
+ /**
23
+ * creates an Observable<boolean> from the Page Visibility API.
24
+ * emits true when the page is visible, false when hidden.
25
+ * in Node.js (no document), returns an always-true observable.
26
+ */
27
+ function createVisibilityObservable(): Observable<boolean> {
28
+ if (typeof document === 'undefined') {
29
+ return of(true);
30
+ }
31
+ return new Observable<boolean>((subscriber) => {
32
+ subscriber.next(!document.hidden);
33
+ const handler = () => subscriber.next(!document.hidden);
34
+ document.addEventListener('visibilitychange', handler);
35
+ return () => document.removeEventListener('visibilitychange', handler);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * a managed, pausable process that ties an observable producer to state updates.
41
+ * supports lifecycle management (start/pause/resume/dispose) and auto-pause signals.
42
+ */
43
+ export class StateProcess<TStatePartName, TStatePayload, TProducerValue> {
44
+ private readonly statePartRef: StatePart<TStatePartName, TStatePayload>;
45
+ private readonly producerFn: () => Observable<TProducerValue>;
46
+ private readonly reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
47
+ private readonly sideEffect?: (value: TProducerValue) => Promise<void> | void;
48
+ private readonly autoPauseOption: TAutoPause;
49
+
50
+ private statusSubject = new BehaviorSubject<TProcessStatus>('idle');
51
+ private producerSubscription: Subscription | null = null;
52
+ private autoPauseSubscription: Subscription | null = null;
53
+ private processingQueue: Promise<void> = Promise.resolve();
54
+
55
+ constructor(
56
+ statePartRef: StatePart<TStatePartName, TStatePayload>,
57
+ options: {
58
+ producer: () => Observable<TProducerValue>;
59
+ reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
60
+ sideEffect?: (value: TProducerValue) => Promise<void> | void;
61
+ autoPause?: TAutoPause;
62
+ }
63
+ ) {
64
+ this.statePartRef = statePartRef;
65
+ this.producerFn = options.producer;
66
+ this.reducer = options.reducer;
67
+ this.sideEffect = options.sideEffect;
68
+ this.autoPauseOption = options.autoPause ?? false;
69
+ }
70
+
71
+ public get status(): TProcessStatus {
72
+ return this.statusSubject.getValue();
73
+ }
74
+
75
+ public get status$(): Observable<TProcessStatus> {
76
+ return this.statusSubject.asObservable();
77
+ }
78
+
79
+ public start(): void {
80
+ if (this.status === 'disposed') {
81
+ throw new Error('Cannot start a disposed process');
82
+ }
83
+ if (this.status === 'running') return;
84
+ this.statusSubject.next('running');
85
+ this.subscribeProducer();
86
+ this.setupAutoPause();
87
+ }
88
+
89
+ public pause(): void {
90
+ if (this.status === 'disposed') {
91
+ throw new Error('Cannot pause a disposed process');
92
+ }
93
+ if (this.status !== 'running') return;
94
+ this.statusSubject.next('paused');
95
+ this.unsubscribeProducer();
96
+ }
97
+
98
+ public resume(): void {
99
+ if (this.status === 'disposed') {
100
+ throw new Error('Cannot resume a disposed process');
101
+ }
102
+ if (this.status !== 'paused') return;
103
+ this.statusSubject.next('running');
104
+ this.subscribeProducer();
105
+ }
106
+
107
+ public dispose(): void {
108
+ if (this.status === 'disposed') return;
109
+ this.unsubscribeProducer();
110
+ this.teardownAutoPause();
111
+ this.statusSubject.next('disposed');
112
+ this.statusSubject.complete();
113
+ this.statePartRef._removeProcess(this);
114
+ }
115
+
116
+ private subscribeProducer(): void {
117
+ this.unsubscribeProducer();
118
+ const source = this.producerFn();
119
+ this.producerSubscription = source.subscribe({
120
+ next: (value) => {
121
+ // Queue value processing to ensure each reads fresh state after the previous completes
122
+ this.processingQueue = this.processingQueue.then(async () => {
123
+ try {
124
+ if (this.sideEffect) {
125
+ await this.sideEffect(value);
126
+ } else if (this.reducer) {
127
+ const currentState = this.statePartRef.getState();
128
+ if (currentState !== undefined) {
129
+ await this.statePartRef.setState(this.reducer(currentState, value));
130
+ }
131
+ }
132
+ } catch (err) {
133
+ console.error('StateProcess value handling error:', err);
134
+ }
135
+ });
136
+ },
137
+ error: (err) => {
138
+ console.error('StateProcess producer error:', err);
139
+ if (this.status === 'running') {
140
+ this.statusSubject.next('paused');
141
+ this.unsubscribeProducer();
142
+ }
143
+ },
144
+ });
145
+ }
146
+
147
+ private unsubscribeProducer(): void {
148
+ if (this.producerSubscription) {
149
+ this.producerSubscription.unsubscribe();
150
+ this.producerSubscription = null;
151
+ }
152
+ }
153
+
154
+ private setupAutoPause(): void {
155
+ this.teardownAutoPause();
156
+ if (!this.autoPauseOption) return;
157
+
158
+ const signal$ = this.autoPauseOption === 'visibility'
159
+ ? createVisibilityObservable()
160
+ : this.autoPauseOption;
161
+
162
+ this.autoPauseSubscription = signal$.subscribe((active) => {
163
+ if (!active && this.status === 'running') {
164
+ this.pause();
165
+ } else if (active && this.status === 'paused') {
166
+ this.resume();
167
+ }
168
+ });
169
+ }
170
+
171
+ private teardownAutoPause(): void {
172
+ if (this.autoPauseSubscription) {
173
+ this.autoPauseSubscription.unsubscribe();
174
+ this.autoPauseSubscription = null;
175
+ }
176
+ }
177
+ }
File without changes