@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.
- package/dist_bundle/bundle.js +15 -14901
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/smartstate.classes.computed.js +3 -3
- package/dist_ts/smartstate.classes.smartstate.d.ts +4 -0
- package/dist_ts/smartstate.classes.smartstate.js +22 -3
- package/dist_ts/smartstate.classes.stateaction.d.ts +3 -3
- package/dist_ts/smartstate.classes.stateaction.js +1 -1
- package/dist_ts/smartstate.classes.statepart.d.ts +15 -2
- package/dist_ts/smartstate.classes.statepart.js +69 -12
- package/dist_ts/smartstate.classes.stateprocess.d.ts +48 -0
- package/dist_ts/smartstate.classes.stateprocess.js +138 -0
- package/package.json +13 -13
- package/readme.md +209 -17
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/smartstate.classes.computed.ts +2 -1
- package/ts/smartstate.classes.smartstate.ts +22 -3
- package/ts/smartstate.classes.stateaction.ts +3 -3
- package/ts/smartstate.classes.statepart.ts +85 -20
- package/ts/smartstate.classes.stateprocess.ts +177 -0
- /package/{npmextra.json → .smartconfig.json} +0 -0
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|