@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.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/smartstate.classes.computed.d.ts +7 -0
- package/dist_ts/smartstate.classes.computed.js +10 -0
- package/dist_ts/smartstate.classes.smartstate.d.ts +20 -10
- package/dist_ts/smartstate.classes.smartstate.js +55 -18
- package/dist_ts/smartstate.classes.stateaction.js +1 -2
- package/dist_ts/smartstate.classes.statepart.d.ts +35 -10
- package/dist_ts/smartstate.classes.statepart.js +166 -40
- package/dist_ts/smartstate.contextprovider.d.ts +16 -0
- package/dist_ts/smartstate.contextprovider.js +44 -0
- package/npmextra.json +8 -2
- package/package.json +9 -4
- package/readme.hints.md +47 -30
- package/readme.md +275 -202
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/index.ts +2 -0
- package/ts/smartstate.classes.computed.ts +16 -0
- package/ts/smartstate.classes.smartstate.ts +61 -18
- package/ts/smartstate.classes.stateaction.ts +0 -1
- package/ts/smartstate.classes.statepart.ts +196 -41
- package/ts/smartstate.contextprovider.ts +61 -0
|
@@ -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
|
-
|
|
47
|
-
break;
|
|
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
|
-
|
|
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,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;
|
|
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
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
|
119
|
+
// Save to WebStore first to ensure atomicity
|
|
57
120
|
if (this.webStore) {
|
|
58
|
-
await this.webStore.set(String(this.name),
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
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(
|
|
180
|
+
this.pendingCumulativeNotification = setTimeout(() => {
|
|
111
181
|
this.pendingCumulativeNotification = null;
|
|
112
|
-
if (this.stateStore) {
|
|
113
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|