-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathHoistBase.ts
More file actions
411 lines (372 loc) · 15.1 KB
/
Copy pathHoistBase.ts
File metadata and controls
411 lines (372 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
/*
* This file belongs to Hoist, an application development toolkit
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
*
* Copyright © 2026 Extremely Heavy Industries Inc.
*/
import {
action,
autorun as mobxAutorun,
checkMakeObservable,
comparer,
reaction as mobxReaction,
runInAction,
when as mobxWhen
} from '@xh/hoist/mobx';
import {
apiDeprecated,
getOrCreate,
logDebug,
logError,
logInfo,
logWarn,
throwIf,
withDebug,
withInfo
} from '@xh/hoist/utils/js';
import {Runner} from './runner/Runner';
import {
debounce as lodashDebounce,
isFunction,
isNil,
isNumber,
isPlainObject,
isString,
upperFirst
} from 'lodash';
import {IAutorunOptions, IReactionOptions} from 'mobx/dist/api/autorun';
import {IEqualsComparer, IReactionDisposer} from 'mobx/dist/internal';
import {
CallContextLike,
DebounceSpec,
PersistableState,
PersistenceProvider,
persistOptions,
PersistOptions,
FullSpanConfig,
Some,
Span,
XH
} from './';
import {wait} from '@xh/hoist/promise';
declare const xhIsDevelopmentMode: boolean;
export interface HoistBaseClass {
new (...args: any[]): HoistBase;
isHoistBase: boolean;
}
/**
* Base class for objects in Hoist.
* Provides misc. support for Mobx integration, state persistence, and resource cleanup.
*
* This class should not typically be extended directly by applications. Applications should
* extend one of its subclasses instead, notably:
* @see HoistModel
* @see HoistService
* @see Store
*
* @mcpHint base class for all Hoist objects (models, services, stores)
*/
export abstract class HoistBase {
static get isHoistBase(): boolean {
return true;
}
get isHoistBase(): boolean {
return true;
}
constructor() {
if (xhIsDevelopmentMode) {
wait().then(() => checkMakeObservable(this));
}
}
/**
* For XH internal use only - marks this instance as created by and for Hoist as part of its
* own implementation. Used as a filter within Hoist Inspector to distinguish services and
* models that are either created directly by the app developer or important/public parts of
* the Hoist API from those that are not.
* @internal
*/
xhImpl: boolean = undefined;
// Internal State
private managedInstances = [];
private disposers = [];
private _destroyed = false;
private _xhImpl: boolean;
/** Default persistence options for this object. */
persistWith: PersistOptions = null;
/**
* Optional prefix applied to span names produced for this object.
* When non-null, the string "[prefix]." is pre-pended to the supplied span name.
*/
telemetryPrefix: string = null;
//--------------------------------------------------
// Logging Delegates
//--------------------------------------------------
logInfo(...messages: unknown[]) {
logInfo(messages, this);
}
logWarn(...messages: unknown[]) {
logWarn(messages, this);
}
logError(...messages: unknown[]) {
logError(messages, this);
}
logDebug(...messages: unknown[]) {
logDebug(messages, this);
}
withInfo<T>(messages: Some<unknown>, fn: () => T): T {
return withInfo<T>(messages, fn, this);
}
withDebug<T>(messages: Some<unknown>, fn: () => T): T {
return withDebug<T>(messages, fn, this);
}
/** @deprecated - use {@link runner} to start a {@link Runner} chain. */
withSpan<T>(config: string | FullSpanConfig, fn: (span: Span) => Promise<T>): Promise<T> {
apiDeprecated('HoistBase.withSpan', {
v: 'v88',
msg: 'Use runner().span() to start a Runner chain instead.',
source: this
});
let cfg = isString(config) ? {name: config} : config,
{telemetryPrefix} = this,
name = telemetryPrefix ? telemetryPrefix + '.' + cfg.name : cfg.name;
cfg = {caller: this, ...cfg, name};
return XH.traceService.withSpan(cfg, fn);
}
/**
* Create a {@link Runner} with an optional initial call context and this object as the caller.
*/
runner(ctx: CallContextLike = {}): Runner {
return Runner.create(ctx, this);
}
/**
* Add and start one or more managed reactions.
*
* A reaction's run function will be executed on changes to any/all observables read in its
* track function, regardless of whether they - or any other observables - are accessed in
* the run function. The reaction will also run only when the output of the track function
* changes, and this output is passed to the run function.
*
* Specify the property 'track' to run the reaction continuously until disposal.
* Alternatively, specify the 'when' property to run this reaction only until the predicate
* passes, and the run function is executed once. (These map to mobX's native `reaction()`
* and `when()` functions, respectively).
*
* Specify the property 'equals' to determine how successive outputs of track will be compared.
* Hoist supports string specification of this (i.e. 'shallow','structural', or 'identity') and
* will map it to the underlying MobX `comparer` object. For returns of arrays and objects,
* consider using the value 'shallow' over the default 'identity' to avoid triggering spurious
* changes. See MobX for more information.
*
* Choose this method over an autorun when you wish to explicitly declare which observables
* should be tracked. A common pattern is to have the track function return these
* observables in a simple array or object, which the run function can use as its input or
* (commonly) ignore. This helps to clarify that the track function is only enumerating
* the observables to be watched, and not necessarily generating or transforming values.
*
* Set `fireImmediately: true` to execute the `run` function once immediately with the
* current value of the tracked expression, rather than waiting for it to change. This is
* useful for syncing initial state without duplicating logic outside the reaction.
*
* Reactions created in this method will be disposed of automatically when this object is
* destroyed. They can also be ended/disposed of manually using the native MobX disposer
* functions returned by this method.
*
* @param specs - one or more reactions to add
* @returns disposer(s) to manually dispose of each created reaction.
*/
addReaction<T>(spec: ReactionSpec<T>): IReactionDisposer;
addReaction<T extends any[]>(
...specs: {[K in keyof T]: ReactionSpec<T[K]>}
): IReactionDisposer[];
addReaction(...specs: ReactionSpec[]): IReactionDisposer | IReactionDisposer[] {
const disposers = specs.map(s => {
if (!s) return null;
let {track, when, run, debounce, ...rest} = s;
throwIf(
(track && when) || (!track && !when),
"Must specify either 'track' or 'when' in addReaction."
);
const opts = parseReactionOptions(rest);
run = bindAndDebounce(this, run, debounce);
const disposer = track ? mobxReaction(track, run, opts) : mobxWhen(when, run, opts);
this.disposers.push(disposer);
return disposer;
});
return disposers.length === 1 ? disposers[0] : disposers;
}
/**
* Add and start one or more managed autoruns.
*
* An autorun function will be run on changes to any/all observables read during the last
* execution of the function. This provides convenient and often very efficient dynamic
* reactivity. This is a core MobX concept and is important to fully understand when
* using autorun functions.
*
* In some cases, however, it is desirable or more clear to explicitly declare which
* observables should be tracked and trigger a reaction, regardless of their use within
* the function itself. See addReaction() above for that functionality.
*
* Autoruns created in this method will be disposed of automatically when this object is
* destroyed. They can also be ended/disposed of manually using the native mobx disposer
* functions returned by this method.
*
* @param specs - one or more autoruns to add
* @returns disposer(s) to manually dispose of each created autorun.
*/
addAutorun(
...specs: Array<AutoRunSpec | (() => any)>
): IReactionDisposer | Array<IReactionDisposer> {
const disposers = specs.map(s => {
if (!s) return null;
if (isFunction(s)) s = {run: s};
let {run, ...opts} = s;
run = bindAndDebounce(this, run);
const disposer = mobxAutorun(run, opts);
this.disposers.push(disposer);
return disposer;
});
return disposers.length === 1 ? disposers[0] : disposers;
}
/**
* Set an observable/bindable value.
*
* This method is a convenience method for calling the conventional setXXX method
* for updating a mobx observable given the property name.
*/
setBindable(property: string, value: any) {
const setter = `set${upperFirst(property)}`;
throwIf(
!isFunction(this[setter]),
`Required function '${setter}()' not found on bound model. ` +
`Implement a setter, or use the @bindable annotation.`
);
this[setter].call(this, value);
}
/** @returns a unique id for this object within the lifetime of this document. */
get xhId(): string {
return getOrCreate(this, '_xhId', XH.genId);
}
/**
* Mark an object as managed by this object.
*
* Managed objects are assumed to hold objects that are created by the referencing object
* and therefore should be destroyed when the referencing object is destroyed.
*
* See also {@link managed}, a decorator that can be used to mark any object held within
* a given property as managed.
*
* @param obj - object to be destroyed when this instance is destroyed
* @returns object passed
*/
markManaged<T>(obj: T): T {
// If markManaged is unexpectedly called on an object after this instance has been
// destroyed - e.g. in an async callback - destroy it immediately.
if (this.isDestroyed) {
XH.safeDestroy(obj);
} else {
this.managedInstances.push(obj);
}
return obj;
}
/**
* Method to make a class property persistent, syncing its value via a configured
* `PersistenceProvider` to maintain and restore values across browser sessions.
*
* This may be used on any `@observable` or `@bindable` class property which is a primitive.
* It will initialize the observable's value from the class's default `PersistenceProvider` and
* will write back any changes to the property to that Provider. If the Provider has not yet
* been populated with a value, or an error occurs, it will use the value set in-code instead.
*
* See also {@link persist} and {@link persist.with} for a decorator that may be used directly
* on the property declaration itself. Use this method in the general case, when you need to
* control the timing.
*
* @param property - name of property on this object to bind to a persistence provider.
* @param options - options governing the persistence of this object. These will be applied
* on top of any default persistWith options defined on the instance itself.
*/
markPersist<P extends keyof this & string>(property: P, options: PersistOptions = {}) {
// Read from and attach to Provider, failing gently
PersistenceProvider.create({
persistOptions: persistOptions({path: property}, this.persistWith, options),
owner: this,
target: {
getPersistableState: () => new PersistableState(this[property]),
setPersistableState: state => runInAction(() => (this[property] = state.value))
}
});
}
/** @returns true if this instance has been destroyed. */
get isDestroyed() {
return this._destroyed;
}
/**
* Clean up resources associated with this object.
*/
destroy() {
// If a model is being destroyed or has already been destroyed, no need to destroy it again.
// Prevents stack overflow in case this model gets a managed reference chain back to itself.
if (this.isDestroyed) {
this.logWarn('Destruction skipped - this model is already destroyed.');
return;
}
this._destroyed = true;
this.disposers.forEach(f => f());
this.managedInstances.forEach(i => XH.safeDestroy(i));
this['_xhManagedProperties']?.forEach(p => XH.safeDestroy(this[p]));
}
}
/**
* Object containing options accepted by MobX 'reaction' API as well as arguments below.
* Also supports MobX options inherited from IReactionOptions, including `fireImmediately`
* (run the reaction once immediately with the current tracked value).
*/
export interface ReactionSpec<T = any> extends Omit<IReactionOptions<T, any>, 'equals'> {
/**
* Function returning data to observe - first arg to the underlying reaction() call.
* Specify this or `when`.
*/
track?: () => T;
/**
* Function determining when reaction should fire - first arg to the underlying when() call.
* Specify this or `track`.
*/
when?: () => boolean;
/** Function to run - second arg to underlying reaction()/when() call. */
run?: (curr?: T, prev?: T) => void;
/** Specify to debounce run function */
debounce?: DebounceSpec;
/** Specify a default from {@link comparer} or a custom comparer function. */
equals?: keyof typeof comparer | IEqualsComparer<T>;
}
/**
* Object containing options accepted by MobX 'autorun' API as well as arguments below.
*/
export interface AutoRunSpec extends IAutorunOptions {
/** Function to run - first arg to underlying autorun() call. */
run?: () => void;
}
//--------------------------------------------------
// Implementation
// Externalized to make private, obj is the instance
//--------------------------------------------------
function parseReactionOptions(options) {
throwIf(
!isNil(options.runImmediately),
'"runImmediately" is not a reaction option. Did you mean "fireImmediately"?'
);
if (isString(options.equals)) {
const equals = comparer[options.equals];
throwIf(!isFunction(equals), `Unknown value for equals: '${options.equals}'`);
options = {...options, equals};
}
return options;
}
function bindAndDebounce(obj, fn, debounce = null) {
let ret = fn.bind(obj);
// See https://github.com/mobxjs/mobx/issues/1956 and note we cannot use mobx scheduler.
// ensure the async run of the effect also occurs in action as expected.
if (isNumber(debounce)) return lodashDebounce(action(ret), debounce);
if (isPlainObject(debounce)) return lodashDebounce(action(ret), debounce.interval, debounce);
return ret;
}