@push.rocks/smartstate 2.0.31 → 2.1.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.
@@ -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,57 @@ 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 pendingNotifications = new Set<StatePart<any, any>>();
18
+
14
19
  constructor() {}
15
20
 
21
+ /**
22
+ * whether state changes are currently being batched
23
+ */
24
+ public get isBatching(): boolean {
25
+ return this.batchDepth > 0;
26
+ }
27
+
28
+ /**
29
+ * registers a state part for deferred notification during a batch
30
+ */
31
+ public registerPendingNotification(statePart: StatePart<any, any>): void {
32
+ this.pendingNotifications.add(statePart);
33
+ }
34
+
35
+ /**
36
+ * batches multiple state updates so subscribers are only notified once all updates complete
37
+ */
38
+ public async batch(updateFn: () => Promise<void> | void): Promise<void> {
39
+ this.batchDepth++;
40
+ try {
41
+ await updateFn();
42
+ } finally {
43
+ this.batchDepth--;
44
+ if (this.batchDepth === 0) {
45
+ const pending = [...this.pendingNotifications];
46
+ this.pendingNotifications.clear();
47
+ for (const sp of pending) {
48
+ await sp.notifyChange();
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * creates a computed observable derived from multiple state parts
56
+ */
57
+ public computed<TResult>(
58
+ sources: StatePart<StatePartNameType, any>[],
59
+ computeFn: (...states: any[]) => TResult,
60
+ ): plugins.smartrx.rxjs.Observable<TResult> {
61
+ return computed(sources, computeFn);
62
+ }
63
+
16
64
  /**
17
65
  * 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
66
  */
26
67
  public async getStatePart<PayloadType>(
27
68
  statePartNameArg: StatePartNameType,
@@ -43,16 +84,13 @@ export class Smartstate<StatePartNameType extends string> {
43
84
  `State part '${statePartNameArg}' already exists, but initMode is 'mandatory'`
44
85
  );
45
86
  case 'force':
46
- // Force mode: create new state part
47
- break; // Fall through to creation
87
+ break;
48
88
  case 'soft':
49
89
  case 'persistent':
50
90
  default:
51
- // Return existing state part
52
91
  return existingStatePart as StatePart<StatePartNameType, PayloadType>;
53
92
  }
54
93
  } else {
55
- // State part doesn't exist
56
94
  if (!initialArg) {
57
95
  throw new Error(
58
96
  `State part '${statePartNameArg}' does not exist and no initial state provided`
@@ -73,9 +111,6 @@ export class Smartstate<StatePartNameType extends string> {
73
111
 
74
112
  /**
75
113
  * Creates a statepart
76
- * @param statePartName
77
- * @param initialPayloadArg
78
- * @param initMode
79
114
  */
80
115
  private async createStatePart<PayloadType>(
81
116
  statePartName: StatePartNameType,
@@ -91,21 +126,20 @@ export class Smartstate<StatePartNameType extends string> {
91
126
  }
92
127
  : null
93
128
  );
129
+ newState.smartstateRef = this;
94
130
  await newState.init();
95
131
  const currentState = newState.getState();
96
132
 
97
133
  if (initMode === 'persistent' && currentState !== undefined) {
98
- // Persisted state exists - merge with defaults, persisted values take precedence
99
134
  await newState.setState({
100
135
  ...initialPayloadArg,
101
136
  ...currentState,
102
137
  });
103
138
  } else {
104
- // No persisted state or non-persistent mode
105
139
  await newState.setState(initialPayloadArg);
106
140
  }
107
141
 
108
142
  this.statePartMap[statePartName] = newState;
109
143
  return newState;
110
144
  }
111
- }
145
+ }
@@ -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
 
10
37
  private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
38
+ private pendingBatchNotification = false;
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
  }
@@ -43,23 +76,43 @@ export class StatePart<TStatePartName, TStatePayload> {
43
76
  return this.stateStore;
44
77
  }
45
78
 
79
+ /**
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
+
46
94
  /**
47
95
  * sets the stateStore to the new state
48
- * @param newStateArg
49
96
  */
50
97
  public async setState(newStateArg: TStatePayload) {
98
+ // Run middleware chain
99
+ let processedState = newStateArg;
100
+ for (const mw of this.middlewares) {
101
+ processedState = await mw(processedState, this.stateStore);
102
+ }
103
+
51
104
  // Validate state structure
52
- if (!this.validateState(newStateArg)) {
105
+ if (!this.validateState(processedState)) {
53
106
  throw new Error(`Invalid state structure for state part '${this.name}'`);
54
107
  }
55
108
 
56
- // Save to WebStore first to ensure atomicity - if save fails, memory state remains unchanged
109
+ // Save to WebStore first to ensure atomicity
57
110
  if (this.webStore) {
58
- await this.webStore.set(String(this.name), newStateArg);
111
+ await this.webStore.set(String(this.name), processedState);
59
112
  }
60
113
 
61
114
  // Update in-memory state after successful persistence
62
- this.stateStore = newStateArg;
115
+ this.stateStore = processedState;
63
116
  await this.notifyChange();
64
117
 
65
118
  return this.stateStore;
@@ -67,11 +120,8 @@ export class StatePart<TStatePartName, TStatePayload> {
67
120
 
68
121
  /**
69
122
  * Validates state structure - can be overridden for custom validation
70
- * @param stateArg
71
123
  */
72
124
  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
125
  return stateArg !== null && stateArg !== undefined;
76
126
  }
77
127
 
@@ -82,6 +132,14 @@ export class StatePart<TStatePartName, TStatePayload> {
82
132
  if (!this.stateStore) {
83
133
  return;
84
134
  }
135
+
136
+ // If inside a batch, defer the notification
137
+ if (this.smartstateRef?.isBatching) {
138
+ this.pendingBatchNotification = true;
139
+ this.smartstateRef.registerPendingNotification(this);
140
+ return;
141
+ }
142
+
85
143
  const createStateHash = async (stateArg: any) => {
86
144
  return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
87
145
  };
@@ -99,10 +157,9 @@ export class StatePart<TStatePartName, TStatePayload> {
99
157
  private lastStateNotificationPayloadHash: any;
100
158
 
101
159
  /**
102
- * creates a cumulative notification by adding a change notification at the end of the call stack;
160
+ * creates a cumulative notification by adding a change notification at the end of the call stack
103
161
  */
104
162
  public notifyChangeCumulative() {
105
- // Debounce: clear any pending notification
106
163
  if (this.pendingCumulativeNotification) {
107
164
  clearTimeout(this.pendingCumulativeNotification);
108
165
  }
@@ -116,27 +173,56 @@ export class StatePart<TStatePartName, TStatePayload> {
116
173
  }
117
174
 
118
175
  /**
119
- * selects a state or a substate
176
+ * selects a state or a substate.
177
+ * supports an optional AbortSignal for automatic unsubscription.
178
+ * memoizes observables by selector function reference.
120
179
  */
121
180
  public select<T = TStatePayload>(
122
- selectorFn?: (state: TStatePayload) => T
181
+ selectorFn?: (state: TStatePayload) => T,
182
+ options?: { signal?: AbortSignal }
123
183
  ): plugins.smartrx.rxjs.Observable<T> {
124
- if (!selectorFn) {
125
- selectorFn = (state: TStatePayload) => <T>(<any>state);
184
+ const hasSignal = options?.signal != null;
185
+
186
+ // Check memoization cache (only for non-signal selects)
187
+ if (!hasSignal) {
188
+ if (!selectorFn) {
189
+ if (this.defaultSelectObservable) {
190
+ return this.defaultSelectObservable as unknown as plugins.smartrx.rxjs.Observable<T>;
191
+ }
192
+ } else if (this.selectorCache.has(selectorFn)) {
193
+ return this.selectorCache.get(selectorFn)!;
194
+ }
126
195
  }
127
- const mapped = this.state.pipe(
196
+
197
+ const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
198
+
199
+ let mapped = this.state.pipe(
128
200
  plugins.smartrx.rxjs.ops.startWith(this.getState()),
129
201
  plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined),
130
202
  plugins.smartrx.rxjs.ops.map((stateArg) => {
131
203
  try {
132
- return selectorFn(stateArg);
204
+ return effectiveSelectorFn(stateArg);
133
205
  } catch (e) {
134
206
  console.error(`Selector error in state part '${this.name}':`, e);
135
207
  return undefined;
136
208
  }
137
209
  })
138
210
  );
139
- return mapped;
211
+
212
+ if (hasSignal) {
213
+ mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal)));
214
+ return mapped;
215
+ }
216
+
217
+ // Apply shareReplay for caching and store in memo cache
218
+ const shared = mapped.pipe(shareReplay({ bufferSize: 1, refCount: true }));
219
+ if (!selectorFn) {
220
+ this.defaultSelectObservable = shared as unknown as plugins.smartrx.rxjs.Observable<TStatePayload>;
221
+ } else {
222
+ this.selectorCache.set(selectorFn, shared);
223
+ }
224
+
225
+ return shared;
140
226
  }
141
227
 
142
228
  /**
@@ -159,18 +245,32 @@ export class StatePart<TStatePartName, TStatePayload> {
159
245
  }
160
246
 
161
247
  /**
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
248
+ * waits until a certain part of the state becomes available.
249
+ * supports optional timeout and AbortSignal.
165
250
  */
166
251
  public async waitUntilPresent<T = TStatePayload>(
167
252
  selectorFn?: (state: TStatePayload) => T,
168
- timeoutMs?: number
253
+ optionsOrTimeout?: number | { timeoutMs?: number; signal?: AbortSignal }
169
254
  ): Promise<T> {
255
+ // Parse backward-compatible args
256
+ let timeoutMs: number | undefined;
257
+ let signal: AbortSignal | undefined;
258
+ if (typeof optionsOrTimeout === 'number') {
259
+ timeoutMs = optionsOrTimeout;
260
+ } else if (optionsOrTimeout) {
261
+ timeoutMs = optionsOrTimeout.timeoutMs;
262
+ signal = optionsOrTimeout.signal;
263
+ }
264
+
170
265
  const done = plugins.smartpromise.defer<T>();
171
266
  const selectedObservable = this.select(selectorFn);
172
267
  let resolved = false;
173
268
 
269
+ // Check if already aborted
270
+ if (signal?.aborted) {
271
+ throw new Error('Aborted');
272
+ }
273
+
174
274
  const subscription = selectedObservable.subscribe((value) => {
175
275
  if (value && !resolved) {
176
276
  resolved = true;
@@ -189,12 +289,29 @@ export class StatePart<TStatePartName, TStatePayload> {
189
289
  }, timeoutMs);
190
290
  }
191
291
 
292
+ // Handle abort signal
293
+ const abortHandler = signal ? () => {
294
+ if (!resolved) {
295
+ resolved = true;
296
+ subscription.unsubscribe();
297
+ if (timeoutId) clearTimeout(timeoutId);
298
+ done.reject(new Error('Aborted'));
299
+ }
300
+ } : undefined;
301
+
302
+ if (signal && abortHandler) {
303
+ signal.addEventListener('abort', abortHandler);
304
+ }
305
+
192
306
  try {
193
307
  const result = await done.promise;
194
308
  return result;
195
309
  } finally {
196
310
  subscription.unsubscribe();
197
311
  if (timeoutId) clearTimeout(timeoutId);
312
+ if (signal && abortHandler) {
313
+ signal.removeEventListener('abort', abortHandler);
314
+ }
198
315
  }
199
316
  }
200
317
 
@@ -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
+ }