@push.rocks/smartstate 2.0.31 → 2.1.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,5 +1,6 @@
1
1
  import * as plugins from './smartstate.plugins.js';
2
2
  import { StatePart } from './smartstate.classes.statepart.js';
3
+ import { computed } from './smartstate.classes.computed.js';
3
4
 
4
5
  export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent';
5
6
 
@@ -11,17 +12,65 @@ export class Smartstate<StatePartNameType extends string> {
11
12
 
12
13
  private pendingStatePartCreation: Map<string, Promise<StatePart<StatePartNameType, any>>> = new Map();
13
14
 
15
+ // Batch support
16
+ private batchDepth = 0;
17
+ private isFlushing = false;
18
+ private pendingNotifications = new Set<StatePart<any, any>>();
19
+
14
20
  constructor() {}
15
21
 
22
+ /**
23
+ * whether state changes are currently being batched
24
+ */
25
+ public get isBatching(): boolean {
26
+ return this.batchDepth > 0;
27
+ }
28
+
29
+ /**
30
+ * registers a state part for deferred notification during a batch
31
+ */
32
+ public registerPendingNotification(statePart: StatePart<any, any>): void {
33
+ this.pendingNotifications.add(statePart);
34
+ }
35
+
36
+ /**
37
+ * batches multiple state updates so subscribers are only notified once all updates complete
38
+ */
39
+ public async batch(updateFn: () => Promise<void> | void): Promise<void> {
40
+ this.batchDepth++;
41
+ try {
42
+ await updateFn();
43
+ } finally {
44
+ this.batchDepth--;
45
+ if (this.batchDepth === 0 && !this.isFlushing) {
46
+ this.isFlushing = true;
47
+ try {
48
+ while (this.pendingNotifications.size > 0) {
49
+ const pending = [...this.pendingNotifications];
50
+ this.pendingNotifications.clear();
51
+ for (const sp of pending) {
52
+ await sp.notifyChange();
53
+ }
54
+ }
55
+ } finally {
56
+ this.isFlushing = false;
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * creates a computed observable derived from multiple state parts
64
+ */
65
+ public computed<TResult>(
66
+ sources: StatePart<StatePartNameType, any>[],
67
+ computeFn: (...states: any[]) => TResult,
68
+ ): plugins.smartrx.rxjs.Observable<TResult> {
69
+ return computed(sources, computeFn);
70
+ }
71
+
16
72
  /**
17
73
  * Allows getting and initializing a new statepart
18
- * initMode === 'soft' (default) - returns existing statepart if exists, creates new if not
19
- * initMode === 'mandatory' - requires statepart to not exist, fails if it does
20
- * initMode === 'force' - always creates new statepart, overwriting any existing
21
- * initMode === 'persistent' - like 'soft' but with webstore persistence
22
- * @param statePartNameArg
23
- * @param initialArg
24
- * @param initMode
25
74
  */
26
75
  public async getStatePart<PayloadType>(
27
76
  statePartNameArg: StatePartNameType,
@@ -43,17 +92,15 @@ export class Smartstate<StatePartNameType extends string> {
43
92
  `State part '${statePartNameArg}' already exists, but initMode is 'mandatory'`
44
93
  );
45
94
  case 'force':
46
- // Force mode: create new state part
47
- break; // Fall through to creation
95
+ existingStatePart.dispose();
96
+ break;
48
97
  case 'soft':
49
98
  case 'persistent':
50
99
  default:
51
- // Return existing state part
52
100
  return existingStatePart as StatePart<StatePartNameType, PayloadType>;
53
101
  }
54
102
  } else {
55
- // State part doesn't exist
56
- if (!initialArg) {
103
+ if (initialArg === undefined) {
57
104
  throw new Error(
58
105
  `State part '${statePartNameArg}' does not exist and no initial state provided`
59
106
  );
@@ -73,9 +120,6 @@ export class Smartstate<StatePartNameType extends string> {
73
120
 
74
121
  /**
75
122
  * Creates a statepart
76
- * @param statePartName
77
- * @param initialPayloadArg
78
- * @param initMode
79
123
  */
80
124
  private async createStatePart<PayloadType>(
81
125
  statePartName: StatePartNameType,
@@ -91,21 +135,20 @@ export class Smartstate<StatePartNameType extends string> {
91
135
  }
92
136
  : null
93
137
  );
138
+ newState.smartstateRef = this;
94
139
  await newState.init();
95
140
  const currentState = newState.getState();
96
141
 
97
142
  if (initMode === 'persistent' && currentState !== undefined) {
98
- // Persisted state exists - merge with defaults, persisted values take precedence
99
143
  await newState.setState({
100
144
  ...initialPayloadArg,
101
145
  ...currentState,
102
146
  });
103
147
  } else {
104
- // No persisted state or non-persistent mode
105
148
  await newState.setState(initialPayloadArg);
106
149
  }
107
150
 
108
151
  this.statePartMap[statePartName] = newState;
109
152
  return newState;
110
153
  }
111
- }
154
+ }
@@ -1,4 +1,3 @@
1
- import * as plugins from './smartstate.plugins.js';
2
1
  import { StatePart } from './smartstate.classes.statepart.js';
3
2
 
4
3
  export interface IActionDef<TStateType, TActionPayloadType> {
@@ -1,21 +1,54 @@
1
1
  import * as plugins from './smartstate.plugins.js';
2
+ import { Observable, shareReplay, takeUntil } from 'rxjs';
2
3
  import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js';
4
+ import type { Smartstate } from './smartstate.classes.smartstate.js';
5
+
6
+ export type TMiddleware<TPayload> = (
7
+ newState: TPayload,
8
+ oldState: TPayload | undefined,
9
+ ) => TPayload | Promise<TPayload>;
10
+
11
+ /**
12
+ * creates an Observable that emits once when the given AbortSignal fires
13
+ */
14
+ function fromAbortSignal(signal: AbortSignal): Observable<void> {
15
+ return new Observable<void>((subscriber) => {
16
+ if (signal.aborted) {
17
+ subscriber.next();
18
+ subscriber.complete();
19
+ return;
20
+ }
21
+ const handler = () => {
22
+ subscriber.next();
23
+ subscriber.complete();
24
+ };
25
+ signal.addEventListener('abort', handler);
26
+ return () => signal.removeEventListener('abort', handler);
27
+ });
28
+ }
3
29
 
4
30
  export class StatePart<TStatePartName, TStatePayload> {
5
31
  public name: TStatePartName;
6
32
  public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
7
33
  public stateStore: TStatePayload | undefined;
34
+ public smartstateRef?: Smartstate<any>;
8
35
  private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
9
36
 
37
+ private mutationQueue: Promise<any> = Promise.resolve();
10
38
  private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
11
39
 
12
40
  private webStoreOptions: plugins.webstore.IWebStoreOptions;
13
- private webStore: plugins.webstore.WebStore<TStatePayload> | null = null; // Add WebStore instance
41
+ private webStore: plugins.webstore.WebStore<TStatePayload> | null = null;
42
+
43
+ private middlewares: TMiddleware<TStatePayload>[] = [];
44
+
45
+ // Selector memoization
46
+ private selectorCache = new WeakMap<Function, plugins.smartrx.rxjs.Observable<any>>();
47
+ private defaultSelectObservable: plugins.smartrx.rxjs.Observable<TStatePayload> | null = null;
14
48
 
15
49
  constructor(nameArg: TStatePartName, webStoreOptionsArg?: plugins.webstore.IWebStoreOptions) {
16
50
  this.name = nameArg;
17
51
 
18
- // Initialize WebStore if webStoreOptions are provided
19
52
  if (webStoreOptionsArg) {
20
53
  this.webStoreOptions = webStoreOptionsArg;
21
54
  }
@@ -44,22 +77,52 @@ export class StatePart<TStatePartName, TStatePayload> {
44
77
  }
45
78
 
46
79
  /**
47
- * sets the stateStore to the new state
48
- * @param newStateArg
80
+ * adds a middleware that intercepts setState calls.
81
+ * middleware can transform the state or throw to reject it.
82
+ * returns a removal function.
83
+ */
84
+ public addMiddleware(middleware: TMiddleware<TStatePayload>): () => void {
85
+ this.middlewares.push(middleware);
86
+ return () => {
87
+ const idx = this.middlewares.indexOf(middleware);
88
+ if (idx !== -1) {
89
+ this.middlewares.splice(idx, 1);
90
+ }
91
+ };
92
+ }
93
+
94
+ /**
95
+ * sets the stateStore to the new state (serialized via mutation queue)
96
+ */
97
+ public async setState(newStateArg: TStatePayload): Promise<TStatePayload> {
98
+ return this.mutationQueue = this.mutationQueue.then(
99
+ () => this.applyState(newStateArg),
100
+ () => this.applyState(newStateArg),
101
+ );
102
+ }
103
+
104
+ /**
105
+ * applies the state change (middleware → validate → persist → notify)
49
106
  */
50
- public async setState(newStateArg: TStatePayload) {
107
+ private async applyState(newStateArg: TStatePayload): Promise<TStatePayload> {
108
+ // Run middleware chain
109
+ let processedState = newStateArg;
110
+ for (const mw of this.middlewares) {
111
+ processedState = await mw(processedState, this.stateStore);
112
+ }
113
+
51
114
  // Validate state structure
52
- if (!this.validateState(newStateArg)) {
115
+ if (!this.validateState(processedState)) {
53
116
  throw new Error(`Invalid state structure for state part '${this.name}'`);
54
117
  }
55
118
 
56
- // Save to WebStore first to ensure atomicity - if save fails, memory state remains unchanged
119
+ // Save to WebStore first to ensure atomicity
57
120
  if (this.webStore) {
58
- await this.webStore.set(String(this.name), newStateArg);
121
+ await this.webStore.set(String(this.name), processedState);
59
122
  }
60
123
 
61
124
  // Update in-memory state after successful persistence
62
- this.stateStore = newStateArg;
125
+ this.stateStore = processedState;
63
126
  await this.notifyChange();
64
127
 
65
128
  return this.stateStore;
@@ -67,11 +130,8 @@ export class StatePart<TStatePartName, TStatePayload> {
67
130
 
68
131
  /**
69
132
  * Validates state structure - can be overridden for custom validation
70
- * @param stateArg
71
133
  */
72
134
  protected validateState(stateArg: any): stateArg is TStatePayload {
73
- // Basic validation - ensure state is not null/undefined
74
- // Subclasses can override for more specific validation
75
135
  return stateArg !== null && stateArg !== undefined;
76
136
  }
77
137
 
@@ -79,64 +139,105 @@ export class StatePart<TStatePartName, TStatePayload> {
79
139
  * notifies of a change on the state
80
140
  */
81
141
  public async notifyChange() {
82
- if (!this.stateStore) {
142
+ const snapshot = this.stateStore;
143
+ if (snapshot === undefined) {
144
+ return;
145
+ }
146
+
147
+ // If inside a batch, defer the notification
148
+ if (this.smartstateRef?.isBatching) {
149
+ this.smartstateRef.registerPendingNotification(this);
83
150
  return;
84
151
  }
152
+
85
153
  const createStateHash = async (stateArg: any) => {
86
154
  return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
87
155
  };
88
- const currentHash = await createStateHash(this.stateStore);
89
- if (
90
- this.lastStateNotificationPayloadHash &&
91
- currentHash === this.lastStateNotificationPayloadHash
92
- ) {
93
- return;
94
- } else {
156
+ try {
157
+ const currentHash = await createStateHash(snapshot);
158
+ if (
159
+ this.lastStateNotificationPayloadHash &&
160
+ currentHash === this.lastStateNotificationPayloadHash
161
+ ) {
162
+ return;
163
+ }
95
164
  this.lastStateNotificationPayloadHash = currentHash;
165
+ } catch (err) {
166
+ console.error(`State hash computation failed for '${this.name}':`, err);
96
167
  }
97
- this.state.next(this.stateStore);
168
+ this.state.next(snapshot);
98
169
  }
99
170
  private lastStateNotificationPayloadHash: any;
100
171
 
101
172
  /**
102
- * creates a cumulative notification by adding a change notification at the end of the call stack;
173
+ * creates a cumulative notification by adding a change notification at the end of the call stack
103
174
  */
104
175
  public notifyChangeCumulative() {
105
- // Debounce: clear any pending notification
106
176
  if (this.pendingCumulativeNotification) {
107
177
  clearTimeout(this.pendingCumulativeNotification);
108
178
  }
109
179
 
110
- this.pendingCumulativeNotification = setTimeout(async () => {
180
+ this.pendingCumulativeNotification = setTimeout(() => {
111
181
  this.pendingCumulativeNotification = null;
112
- if (this.stateStore) {
113
- await this.notifyChange();
182
+ if (this.stateStore !== undefined) {
183
+ this.notifyChange().catch((err) => {
184
+ console.error(`notifyChangeCumulative failed for '${this.name}':`, err);
185
+ });
114
186
  }
115
187
  }, 0);
116
188
  }
117
189
 
118
190
  /**
119
- * selects a state or a substate
191
+ * selects a state or a substate.
192
+ * supports an optional AbortSignal for automatic unsubscription.
193
+ * memoizes observables by selector function reference.
120
194
  */
121
195
  public select<T = TStatePayload>(
122
- selectorFn?: (state: TStatePayload) => T
196
+ selectorFn?: (state: TStatePayload) => T,
197
+ options?: { signal?: AbortSignal }
123
198
  ): plugins.smartrx.rxjs.Observable<T> {
124
- if (!selectorFn) {
125
- selectorFn = (state: TStatePayload) => <T>(<any>state);
199
+ const hasSignal = options?.signal != null;
200
+
201
+ // Check memoization cache (only for non-signal selects)
202
+ if (!hasSignal) {
203
+ if (!selectorFn) {
204
+ if (this.defaultSelectObservable) {
205
+ return this.defaultSelectObservable as unknown as plugins.smartrx.rxjs.Observable<T>;
206
+ }
207
+ } else if (this.selectorCache.has(selectorFn)) {
208
+ return this.selectorCache.get(selectorFn)!;
209
+ }
126
210
  }
127
- const mapped = this.state.pipe(
211
+
212
+ const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
213
+
214
+ let mapped = this.state.pipe(
128
215
  plugins.smartrx.rxjs.ops.startWith(this.getState()),
129
216
  plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined),
130
217
  plugins.smartrx.rxjs.ops.map((stateArg) => {
131
218
  try {
132
- return selectorFn(stateArg);
219
+ return effectiveSelectorFn(stateArg);
133
220
  } catch (e) {
134
221
  console.error(`Selector error in state part '${this.name}':`, e);
135
222
  return undefined;
136
223
  }
137
224
  })
138
225
  );
139
- return mapped;
226
+
227
+ if (hasSignal) {
228
+ mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal)));
229
+ return mapped;
230
+ }
231
+
232
+ // Apply shareReplay for caching and store in memo cache
233
+ const shared = mapped.pipe(shareReplay({ bufferSize: 1, refCount: true }));
234
+ if (!selectorFn) {
235
+ this.defaultSelectObservable = shared as unknown as plugins.smartrx.rxjs.Observable<TStatePayload>;
236
+ } else {
237
+ this.selectorCache.set(selectorFn, shared);
238
+ }
239
+
240
+ return shared;
140
241
  }
141
242
 
142
243
  /**
@@ -153,26 +254,47 @@ export class StatePart<TStatePartName, TStatePayload> {
153
254
  */
154
255
  public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
155
256
  await this.cumulativeDeferred.promise;
156
- const newState = await stateAction.actionDef(this, actionPayload);
157
- await this.setState(newState);
158
- return this.getState();
257
+ return this.mutationQueue = this.mutationQueue.then(
258
+ async () => {
259
+ const newState = await stateAction.actionDef(this, actionPayload);
260
+ return this.applyState(newState);
261
+ },
262
+ async () => {
263
+ const newState = await stateAction.actionDef(this, actionPayload);
264
+ return this.applyState(newState);
265
+ },
266
+ );
159
267
  }
160
268
 
161
269
  /**
162
- * waits until a certain part of the state becomes available
163
- * @param selectorFn
164
- * @param timeoutMs - optional timeout in milliseconds to prevent indefinite waiting
270
+ * waits until a certain part of the state becomes available.
271
+ * supports optional timeout and AbortSignal.
165
272
  */
166
273
  public async waitUntilPresent<T = TStatePayload>(
167
274
  selectorFn?: (state: TStatePayload) => T,
168
- timeoutMs?: number
275
+ optionsOrTimeout?: number | { timeoutMs?: number; signal?: AbortSignal }
169
276
  ): Promise<T> {
277
+ // Parse backward-compatible args
278
+ let timeoutMs: number | undefined;
279
+ let signal: AbortSignal | undefined;
280
+ if (typeof optionsOrTimeout === 'number') {
281
+ timeoutMs = optionsOrTimeout;
282
+ } else if (optionsOrTimeout) {
283
+ timeoutMs = optionsOrTimeout.timeoutMs;
284
+ signal = optionsOrTimeout.signal;
285
+ }
286
+
170
287
  const done = plugins.smartpromise.defer<T>();
171
288
  const selectedObservable = this.select(selectorFn);
172
289
  let resolved = false;
173
290
 
291
+ // Check if already aborted
292
+ if (signal?.aborted) {
293
+ throw new Error('Aborted');
294
+ }
295
+
174
296
  const subscription = selectedObservable.subscribe((value) => {
175
- if (value && !resolved) {
297
+ if (value !== undefined && value !== null && !resolved) {
176
298
  resolved = true;
177
299
  done.resolve(value);
178
300
  }
@@ -189,12 +311,29 @@ export class StatePart<TStatePartName, TStatePayload> {
189
311
  }, timeoutMs);
190
312
  }
191
313
 
314
+ // Handle abort signal
315
+ const abortHandler = signal ? () => {
316
+ if (!resolved) {
317
+ resolved = true;
318
+ subscription.unsubscribe();
319
+ if (timeoutId) clearTimeout(timeoutId);
320
+ done.reject(new Error('Aborted'));
321
+ }
322
+ } : undefined;
323
+
324
+ if (signal && abortHandler) {
325
+ signal.addEventListener('abort', abortHandler);
326
+ }
327
+
192
328
  try {
193
329
  const result = await done.promise;
194
330
  return result;
195
331
  } finally {
196
332
  subscription.unsubscribe();
197
333
  if (timeoutId) clearTimeout(timeoutId);
334
+ if (signal && abortHandler) {
335
+ signal.removeEventListener('abort', abortHandler);
336
+ }
198
337
  }
199
338
  }
200
339
 
@@ -208,4 +347,20 @@ export class StatePart<TStatePartName, TStatePayload> {
208
347
  this.cumulativeDeferred.addPromise(resultPromise);
209
348
  await this.setState(await resultPromise);
210
349
  }
350
+
351
+ /**
352
+ * disposes the state part, completing the Subject and cleaning up resources
353
+ */
354
+ public dispose(): void {
355
+ this.state.complete();
356
+ if (this.pendingCumulativeNotification) {
357
+ clearTimeout(this.pendingCumulativeNotification);
358
+ this.pendingCumulativeNotification = null;
359
+ }
360
+ this.middlewares.length = 0;
361
+ this.selectorCache = new WeakMap();
362
+ this.defaultSelectObservable = null;
363
+ this.webStore = null;
364
+ this.smartstateRef = undefined;
365
+ }
211
366
  }
@@ -0,0 +1,61 @@
1
+ import type { StatePart } from './smartstate.classes.statepart.js';
2
+
3
+ export interface IContextProviderOptions<TPayload> {
4
+ /** the context key (compared by strict equality) */
5
+ context: unknown;
6
+ /** the state part to provide */
7
+ statePart: StatePart<any, TPayload>;
8
+ /** optional selector to provide a derived value instead of the full state */
9
+ selectorFn?: (state: TPayload) => any;
10
+ }
11
+
12
+ /**
13
+ * attaches a Context Protocol provider to an HTML element.
14
+ * listens for `context-request` events and responds with the state part's value.
15
+ * if subscribe=true, retains the callback and invokes it on every state change.
16
+ * returns a cleanup function that removes the listener and unsubscribes.
17
+ */
18
+ export function attachContextProvider<TPayload>(
19
+ element: HTMLElement,
20
+ options: IContextProviderOptions<TPayload>,
21
+ ): () => void {
22
+ const { context, statePart, selectorFn } = options;
23
+ const subscribers = new Set<(value: any, unsubscribe?: () => void) => void>();
24
+
25
+ const subscription = statePart.select(selectorFn).subscribe((value) => {
26
+ for (const cb of subscribers) {
27
+ cb(value);
28
+ }
29
+ });
30
+
31
+ const getValue = (): any => {
32
+ const state = statePart.getState();
33
+ if (state === undefined) return undefined;
34
+ return selectorFn ? selectorFn(state) : state;
35
+ };
36
+
37
+ const handler = (event: Event) => {
38
+ const e = event as CustomEvent;
39
+ const detail = e.detail;
40
+ if (!detail || detail.context !== context) return;
41
+
42
+ e.stopPropagation();
43
+
44
+ if (detail.subscribe) {
45
+ const cb = detail.callback;
46
+ subscribers.add(cb);
47
+ const unsubscribe = () => subscribers.delete(cb);
48
+ cb(getValue(), unsubscribe);
49
+ } else {
50
+ detail.callback(getValue());
51
+ }
52
+ };
53
+
54
+ element.addEventListener('context-request', handler);
55
+
56
+ return () => {
57
+ element.removeEventListener('context-request', handler);
58
+ subscription.unsubscribe();
59
+ subscribers.clear();
60
+ };
61
+ }